dsh-panel-control 0.0.0-stage → 0.3.12

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/index.js ADDED
@@ -0,0 +1,650 @@
1
+ /**
2
+ * Host half of dsh-panel-control.
3
+ *
4
+ * The client half manages the panel-navigation area of the sidebar. Everything
5
+ * it needs is local except one thing: "auto classify" asks a model to file the
6
+ * entries into groups, and only the Host can reach a model.
7
+ *
8
+ * So this half does exactly one job: it registers one POST route under the
9
+ * /api carrier every DSH Host already serves, and answers it with a single
10
+ * non-streaming completion from the profile's default model. The Client calls
11
+ * it through `ctx.connection.rpc.call`, so authentication, the request
12
+ * envelope and the response envelope stay the Host's own, not ours.
13
+ *
14
+ * Nothing is streamed back: the caller needs a small JSON object, and the reply
15
+ * is one short generation (a few hundred output tokens at most).
16
+ */
17
+
18
+ import { promises as fs } from 'node:fs';
19
+ import os from 'node:os';
20
+ import path from 'node:path';
21
+ import { fileURLToPath } from 'node:url';
22
+
23
+ const PACKAGE = 'dsh-panel-control';
24
+ const ENDPOINT = 'dsh-panel-control/classify';
25
+ const ROUTE_PATH = `/api/${ENDPOINT}`;
26
+
27
+ /**
28
+ * Where the Client's settings live.
29
+ *
30
+ * `localStorage` is not good enough for this: the renderer's origin is the
31
+ * Host's loopback address, and its port changes between app launches, so every
32
+ * restart lands on a fresh, empty storage area and the sidebar silently falls
33
+ * back to registration order. A file under the profile's data directory
34
+ * survives restarts, upgrades and origin changes alike.
35
+ */
36
+ const STATE_DIR = path.join(os.homedir(), '.dsh', 'panel-control');
37
+ const PREFS_FILE = path.join(STATE_DIR, 'prefs.json');
38
+ const HOST_LOG = path.join(STATE_DIR, 'host.log');
39
+ const CLIENT_LOG = path.join(STATE_DIR, 'client.log');
40
+ const MAX_PREFS_BYTES = 256 * 1024;
41
+ const LOG_KEEP_BYTES = 256 * 1024;
42
+
43
+ /** The Client half this Host half ships with, used by the recovery nudge below. */
44
+ const CLIENT_FILE = fileURLToPath(new URL('./client.js', import.meta.url));
45
+
46
+ /** Set `DPC_NUDGE=off` to keep the Host strictly read-only about its own files. */
47
+ const NUDGE_ENABLED = String(process.env.DPC_NUDGE || '').toLowerCase() !== 'off';
48
+
49
+ /** Set once the nudge below actually rewrote the Client file in this process. */
50
+ let recoveredAt = 0;
51
+
52
+ /** `String.fromCharCode(10)`: a literal newline in source is easy to mangle. */
53
+ const NL = String.fromCharCode(10);
54
+
55
+ /** Identifies one Host process, so a log spanning several launches shows which
56
+ * apply/mount lines belong together. */
57
+ const BOOT = Math.random().toString(36).slice(2, 8);
58
+
59
+ /** Route limits: this endpoint spends model tokens, so only local callers and
60
+ * only a small body are accepted. */
61
+ const MAX_BODY_BYTES = 64 * 1024;
62
+ const MAX_ENTRIES = 64;
63
+ const MAX_GROUPS = 4;
64
+ const MAX_OUTPUT_TOKENS = 700;
65
+ const TIMEOUT_MS = 45_000;
66
+
67
+ /* ------------------------------------------------------------------ helpers */
68
+
69
+ function isRecord(value) {
70
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
71
+ }
72
+
73
+ function isLoopbackAuthority(authority) {
74
+ if (typeof authority !== 'string' || authority === '') return false;
75
+ try {
76
+ const url = new URL(`http://${authority}`);
77
+ if (url.username || url.password || url.pathname !== '/' || url.search || url.hash) return false;
78
+ const hostname = url.hostname.replace(/\.$/, '');
79
+ return hostname === 'localhost' || hostname === '::1' || hostname === '[::1]' || /^127\.\d+\.\d+\.\d+$/.test(hostname);
80
+ } catch {
81
+ return false;
82
+ }
83
+ }
84
+
85
+ /**
86
+ * Only the page the Host itself served may call this route.
87
+ *
88
+ * "Host and Origin are both loopback" is not enough: every dev server, every
89
+ * other app's webview and every local playground on this machine is also
90
+ * loopback, so that check let them rewrite the user's layout and spend model
91
+ * tokens. The discriminator is the port: the page is same-origin only when its
92
+ * origin carries the very authority this request arrived on.
93
+ */
94
+ function isOwnRequest(request) {
95
+ const host = request.headers.get('host');
96
+ if (!isLoopbackAuthority(host)) return false;
97
+ // A browser labels where the request came from; anything but same-origin (or
98
+ // a non-browser caller) is a different page and has no business here.
99
+ const site = request.headers.get('sec-fetch-site');
100
+ if (site !== null && site !== 'same-origin' && site !== 'none') return false;
101
+ const origin = request.headers.get('origin');
102
+ if (origin === null) return true;
103
+ try {
104
+ const url = new URL(origin);
105
+ if (url.host === host) return true;
106
+ // The desktop shell serves the page from a custom protocol: that page is
107
+ // still the Host's own, even though its origin is not an http authority.
108
+ return url.protocol === 'dsh-app:' || url.protocol === 'file:';
109
+ } catch {
110
+ return false;
111
+ }
112
+ }
113
+
114
+ function reply(rpcId, result) {
115
+ // Connection's client requires `error.details`, even when empty.
116
+ const value =
117
+ result && result.ok === false
118
+ ? { ...result, error: { ...result.error, details: (result.error && result.error.details) || {} } }
119
+ : result;
120
+ return Response.json({ type: 'server-response', rpcId, result: value });
121
+ }
122
+
123
+ function refusal(code, message) {
124
+ return { ok: false, error: { code, message } };
125
+ }
126
+
127
+ function describeError(error) {
128
+ if (error && typeof error.message === 'string' && error.message) return error.message;
129
+ try {
130
+ return JSON.stringify(error);
131
+ } catch {
132
+ return String(error);
133
+ }
134
+ }
135
+
136
+ /* --------------------------------------------------------------- local state */
137
+
138
+ async function ensureStateDir() {
139
+ await fs.mkdir(STATE_DIR, { recursive: true });
140
+ }
141
+
142
+ async function appendLine(file, line) {
143
+ try {
144
+ await ensureStateDir();
145
+ await fs.appendFile(file, `${new Date().toISOString()} [${BOOT}] ${line}\n`, 'utf8');
146
+ const stat = await fs.stat(file);
147
+ if (stat.size > LOG_KEEP_BYTES) {
148
+ const text = await fs.readFile(file, 'utf8');
149
+ await fs.writeFile(file, text.slice(-Math.floor(LOG_KEEP_BYTES / 2)), 'utf8');
150
+ }
151
+ } catch {
152
+ /* Logging is a convenience: it must never break a request. */
153
+ }
154
+ }
155
+
156
+ const logHost = (line) => {
157
+ void appendLine(HOST_LOG, line);
158
+ };
159
+
160
+ const logClient = (line) => {
161
+ void appendLine(CLIENT_LOG, line);
162
+ };
163
+
164
+ async function readPrefsFile() {
165
+ try {
166
+ const text = await fs.readFile(PREFS_FILE, 'utf8');
167
+ const parsed = JSON.parse(text);
168
+ return isRecord(parsed) ? parsed : null;
169
+ } catch (error) {
170
+ if (error && error.code === 'ENOENT') return null;
171
+ await logHost(`prefs read failed: ${describeError(error)}`);
172
+ return null;
173
+ }
174
+ }
175
+
176
+ async function writePrefsFileNow(prefs) {
177
+ if (!isRecord(prefs)) throw new Error('preferences must be an object');
178
+ const text = JSON.stringify(prefs);
179
+ if (text.length > MAX_PREFS_BYTES) throw new Error('preferences are too large');
180
+ await ensureStateDir();
181
+ const temporary = `${PREFS_FILE}.tmp`;
182
+ await fs.writeFile(temporary, text, 'utf8');
183
+ await fs.rename(temporary, PREFS_FILE);
184
+ return text.length;
185
+ }
186
+
187
+ /**
188
+ * One writer at a time.
189
+ *
190
+ * Two windows (or a retry racing the debounce) otherwise both write the same
191
+ * `.tmp` path: one rename wins, the other fails with EPERM/EEXIST and that save
192
+ * is lost even though the caller was told nothing. A promise chain costs nothing
193
+ * here -- saves are rare and small -- and keeps the last write the one on disk.
194
+ */
195
+ let writeChain = Promise.resolve();
196
+
197
+ function writePrefsFile(prefs) {
198
+ const next = writeChain.then(
199
+ () => writePrefsFileNow(prefs),
200
+ () => writePrefsFileNow(prefs),
201
+ );
202
+ // Keep the chain alive after a rejection: the caller still gets the error.
203
+ writeChain = next.catch(() => {});
204
+ return next;
205
+ }
206
+
207
+ /* ------------------------------------------------------------------- prompt */
208
+
209
+ /**
210
+ * One prompt, no chat history: the model answers a single question about a
211
+ * short list. Everything it needs is inside the message, and the reply is
212
+ * parsed defensively because a model can always answer with prose.
213
+ */
214
+ function buildPrompt(draft) {
215
+ const language = draft.locale === 'zh' ? '中文' : 'English';
216
+ const groupNames = (draft.groups || []).map((group) => group.name).filter(Boolean);
217
+ const entries = draft.entries.map((entry) => ({ id: entry.id, label: entry.label }));
218
+ return [
219
+ `You are filing the entry list of a developer tool's sidebar into a few named sections.`,
220
+ `Answer in ${language}.`,
221
+ ``,
222
+ `Existing section names (reuse them when they fit; you may add new ones):`,
223
+ groupNames.length ? JSON.stringify(groupNames) : '[] (none yet)',
224
+ ``,
225
+ `Entries to file, as JSON:`,
226
+ JSON.stringify(entries),
227
+ ``,
228
+ `Rules:`,
229
+ `- Produce 2 to ${MAX_GROUPS} sections; every entry appears in exactly one section.`,
230
+ `- Section names are short: 2-4 Chinese characters, or 1-3 English words.`,
231
+ `- Group by what the entries are for, not by their order.`,
232
+ `- Answer with a single JSON object and nothing else: {"groups":[{"name":"...","ids":["..."]}]}`,
233
+ `- No prose, no markdown fence, no comments.`,
234
+ ].join('\n');
235
+ }
236
+
237
+ /** Pull the first JSON object out of a model reply (tolerates a code fence). */
238
+ function extractJson(text) {
239
+ const raw = typeof text === 'string' ? text : '';
240
+ const start = raw.indexOf('{');
241
+ const end = raw.lastIndexOf('}');
242
+ if (start < 0 || end <= start) return null;
243
+ try {
244
+ return JSON.parse(raw.slice(start, end + 1));
245
+ } catch {
246
+ return null;
247
+ }
248
+ }
249
+
250
+ /** Keep only the answer this caller can use: known ids, at most MAX_GROUPS
251
+ * sections, short names, no duplicates. */
252
+ function sanitizeAnswer(answer, draft) {
253
+ if (!isRecord(answer) || !Array.isArray(answer.groups)) return null;
254
+ const known = new Set(draft.entries.map((entry) => entry.id));
255
+ const used = new Set();
256
+ const groups = [];
257
+ for (const group of answer.groups) {
258
+ if (!isRecord(group) || !Array.isArray(group.ids)) continue;
259
+ const name = typeof group.name === 'string' ? group.name.trim().slice(0, 24) : '';
260
+ const ids = [];
261
+ for (const id of group.ids) {
262
+ if (typeof id !== 'string' || !known.has(id) || used.has(id)) continue;
263
+ used.add(id);
264
+ ids.push(id);
265
+ }
266
+ if (ids.length === 0) continue;
267
+ groups.push({ name: name || `#${groups.length + 1}`, ids });
268
+ if (groups.length >= MAX_GROUPS) break;
269
+ }
270
+ if (groups.length === 0) return null;
271
+ return { groups };
272
+ }
273
+
274
+ /* --------------------------------------------------------------------- call */
275
+
276
+ function serviceOf(scope, key) {
277
+ try {
278
+ if (scope && typeof scope.get === 'function') {
279
+ const value = scope.get(key);
280
+ if (value !== undefined && value !== null) return value;
281
+ }
282
+ } catch {
283
+ /* a service that is not available yet reads as missing, not as a crash */
284
+ }
285
+ try {
286
+ return scope ? scope[key] : undefined;
287
+ } catch {
288
+ return undefined;
289
+ }
290
+ }
291
+
292
+ function currentModel(scope) {
293
+ const defaults = serviceOf(scope, 'agentDefaultModel');
294
+ if (defaults && typeof defaults.currentSelection === 'function') {
295
+ try {
296
+ const selection = defaults.currentSelection();
297
+ if (selection && typeof selection.provider === 'string' && typeof selection.model === 'string') {
298
+ return selection;
299
+ }
300
+ } catch {
301
+ /* fall through: the LLM service may still know a default */
302
+ }
303
+ }
304
+ return null;
305
+ }
306
+
307
+ /** One non-streaming completion: the joined text of a single stream. */
308
+ async function complete(scope, prompt, signal) {
309
+ const llm = serviceOf(scope, 'llm');
310
+ if (!llm || typeof llm !== 'object') throw new Error('the Host LLM service is not available');
311
+ const selection = currentModel(scope);
312
+ if (!selection) throw new Error('no default model is configured for this profile');
313
+
314
+ const messages = [{ role: 'user', content: [{ type: 'text', text: prompt }] }];
315
+ const request = {
316
+ provider: selection.provider,
317
+ model: selection.model,
318
+ messages,
319
+ maxTokens: MAX_OUTPUT_TOKENS,
320
+ };
321
+ // Filing labels needs no deliberation, and a thinking budget would eat the
322
+ // whole output allowance before a single character of the answer is emitted.
323
+ request.reasoningEffort = 'off';
324
+
325
+ let text = '';
326
+ let finish = null;
327
+ const seen = new Set();
328
+ const consume = async (stream) => {
329
+ for await (const chunk of stream) {
330
+ if (!chunk || typeof chunk !== 'object') continue;
331
+ seen.add(String(chunk.type));
332
+ if (chunk.type === 'text-delta' && typeof chunk.text === 'string') text += chunk.text;
333
+ else if (chunk.type === 'finish') finish = chunk;
334
+ }
335
+ };
336
+
337
+ // `prepareCall` validates the route and freezes the resolved config; the
338
+ // prepared stream only accepts exactly that config, so hand it back as-is.
339
+ let prepared = null;
340
+ if (typeof llm.prepareCall === 'function') prepared = await llm.prepareCall(request, signal);
341
+ if (prepared && typeof prepared.stream === 'function') await consume(prepared.stream(prepared.config));
342
+ else await consume(llm.stream(request));
343
+
344
+ if (finish && isRecord(finish.reason)) {
345
+ const kind = finish.reason.kind;
346
+ if (kind && kind !== 'stop' && kind !== 'max-tokens') throw new Error(`the model stopped early (${kind})`);
347
+ }
348
+ return { text, model: `${selection.provider}/${selection.model}`, seen: Array.from(seen).join('+') };
349
+ }
350
+
351
+ /* ------------------------------------------------------------------ handler */
352
+
353
+ async function classify(scope, payload, signal) {
354
+ if (!isRecord(payload)) return refusal('bad-request', 'The request payload is not an object.');
355
+ const entries = [];
356
+ for (const entry of (Array.isArray(payload.entries) ? payload.entries : []).slice(0, MAX_ENTRIES)) {
357
+ if (!isRecord(entry) || typeof entry.id !== 'string' || entry.id === '') continue;
358
+ entries.push({
359
+ id: entry.id,
360
+ label: typeof entry.label === 'string' && entry.label !== '' ? entry.label.slice(0, 80) : entry.id,
361
+ });
362
+ }
363
+ if (entries.length === 0) return refusal('bad-request', 'There is nothing to file: the entry list is empty.');
364
+
365
+ const groups = [];
366
+ for (const group of (Array.isArray(payload.groups) ? payload.groups : []).slice(0, MAX_GROUPS)) {
367
+ if (!isRecord(group) || typeof group.name !== 'string' || group.name.trim() === '') continue;
368
+ groups.push({ name: group.name.trim().slice(0, 24) });
369
+ }
370
+
371
+ const draft = { entries, groups, locale: payload.locale === 'zh' ? 'zh' : 'en' };
372
+
373
+ const deadline = new AbortController();
374
+ const timer = setTimeout(() => deadline.abort(), TIMEOUT_MS);
375
+ const mergedSignal = () => {
376
+ if (!signal) return deadline.signal;
377
+ if (typeof AbortSignal !== 'undefined' && typeof AbortSignal.any === 'function') {
378
+ return AbortSignal.any([signal, deadline.signal]);
379
+ }
380
+ return deadline.signal;
381
+ };
382
+
383
+ try {
384
+ const answer = await complete(scope, buildPrompt(draft), mergedSignal());
385
+ const parsed = sanitizeAnswer(extractJson(answer.text), draft);
386
+ if (!parsed) {
387
+ logHost(`classify: unusable answer (${answer.seen}, ${answer.text.length} chars)`);
388
+ return refusal('unparsable-answer', 'The model did not answer with usable JSON.');
389
+ }
390
+ const filed = new Set(parsed.groups.flatMap((group) => group.ids));
391
+ logHost(`classify: ${parsed.groups.length} groups over ${filed.size}/${entries.length} entries (${answer.model})`);
392
+ return {
393
+ ok: true,
394
+ value: {
395
+ groups: parsed.groups,
396
+ unfiled: entries.filter((entry) => !filed.has(entry.id)).map((entry) => entry.id),
397
+ model: answer.model,
398
+ },
399
+ };
400
+ } catch (error) {
401
+ const aborted = deadline.signal.aborted || (signal && signal.aborted);
402
+ return refusal(aborted ? 'timeout' : 'model-failed', describeError(error));
403
+ } finally {
404
+ clearTimeout(timer);
405
+ }
406
+ }
407
+
408
+ /* -------------------------------------------------------------------- route */
409
+
410
+ async function handle(scope, request) {
411
+ if (!isOwnRequest(request)) return new Response('forbidden', { status: 403 });
412
+ if (request.method !== 'POST') return new Response('method not allowed', { status: 405 });
413
+ const contentType = request.headers.get('content-type');
414
+ if (!contentType || contentType.split(';', 1)[0].trim().toLowerCase() !== 'application/json') {
415
+ return new Response('content type must be application/json', { status: 415 });
416
+ }
417
+
418
+ // Read the text first: `request.json()` would parse the whole body before the
419
+ // size check could refuse it, which is exactly what the limit exists to stop.
420
+ let raw;
421
+ try {
422
+ raw = await request.text();
423
+ } catch {
424
+ return new Response('body is unreadable', { status: 400 });
425
+ }
426
+ if (raw.length > MAX_BODY_BYTES) return new Response('body too large', { status: 413 });
427
+ let message;
428
+ try {
429
+ message = JSON.parse(raw);
430
+ } catch {
431
+ return new Response('body is not JSON', { status: 400 });
432
+ }
433
+
434
+ const rpcId = isRecord(message) && typeof message.rpcId === 'string' ? message.rpcId : 'invalid-request';
435
+ if (
436
+ !isRecord(message) ||
437
+ message.type !== 'client-request' ||
438
+ typeof message.rpcId !== 'string' ||
439
+ message.method !== ENDPOINT ||
440
+ !isRecord(message.payload)
441
+ ) {
442
+ return reply(rpcId, refusal('gateway/bad-request', 'This is not a panel-control request.'));
443
+ }
444
+ const call = message.payload;
445
+ const method = typeof call.method === 'string' ? call.method : '';
446
+
447
+ try {
448
+ if (method === 'classify') return reply(rpcId, await classify(scope, call.payload, request.signal));
449
+ if (method === 'load') return reply(rpcId, await loadPreferences());
450
+ if (method === 'save') return reply(rpcId, await savePreferences(call.payload));
451
+ if (method === 'inventory') return reply(rpcId, inventorySnapshot(scope));
452
+ if (method === 'report') {
453
+ const detail = isRecord(call.payload) ? call.payload : {};
454
+ const text = JSON.stringify(detail).slice(0, 4_000);
455
+ logClient(`${typeof detail.kind === 'string' ? detail.kind : 'report'} ${text}`);
456
+ return reply(rpcId, { ok: true, value: { logged: true } });
457
+ }
458
+ return reply(rpcId, refusal('unknown-method', `Unknown method: ${String(call.method)}`));
459
+ } catch (error) {
460
+ logHost(`request ${method || '?'} failed: ${describeError(error)}`);
461
+ return reply(rpcId, refusal('handler-failed', describeError(error)));
462
+ }
463
+ }
464
+
465
+ async function loadPreferences() {
466
+ const prefs = await readPrefsFile();
467
+ return { ok: true, value: { prefs, file: PREFS_FILE } };
468
+ }
469
+
470
+ async function savePreferences(payload) {
471
+ if (!isRecord(payload) || !isRecord(payload.prefs)) {
472
+ return refusal('bad-request', 'A preferences object is required.');
473
+ }
474
+ const bytes = await writePrefsFile(payload.prefs);
475
+ return { ok: true, value: { saved: true, bytes } };
476
+ }
477
+
478
+ /**
479
+ * What the Host is serving to pages right now, taken from the same module graph
480
+ * that builds `window.__DSH_BOOT__`. Comparing revisions is the only reliable
481
+ * way to tell "this page booted with an older plugin list": the page cannot
482
+ * read the Host's list, and the profile manifest says nothing about which
483
+ * plugins actually made it into the served graph.
484
+ */
485
+ let clientModules = null;
486
+
487
+ function inventorySnapshot(scope) {
488
+ if (!clientModules) clientModules = serviceOf(scope, 'clientModules');
489
+ const graph = clientModules && typeof clientModules.graph === 'function' ? clientModules.graph() : null;
490
+ if (!graph || typeof graph.rev !== 'string') return { ok: false, error: { message: 'The plugin graph is not readable.' } };
491
+ const entries = Array.isArray(graph.entries) ? graph.entries : [];
492
+ return {
493
+ ok: true,
494
+ value: {
495
+ rev: graph.rev,
496
+ release: typeof graph.release === 'string' ? graph.release : null,
497
+ ids: entries.map((entry) => String(entry && entry.id)).filter(Boolean),
498
+ // True when this process had to hand itself to the page: the page's list is
499
+ // still stale, but the entry it lacks is this very plugin, so there is
500
+ // nothing left for the user to do.
501
+ recovered: recoveredAt > 0,
502
+ },
503
+ };
504
+ }
505
+
506
+ /**
507
+ * Say it out loud when the page never came back.
508
+ *
509
+ * The Host is up (this half ran) and yet the Client half reports nothing: the
510
+ * window was built from a plugin list that no longer matches the code on disk,
511
+ * so its script request went nowhere and the page silently omits the entry. The
512
+ * user sees a plugin that "does not start", so write the diagnosis where it can
513
+ * be found -- once per launch.
514
+ *
515
+ * This runs in a timer, outside every request and outside Cordis' own error
516
+ * handling: anything thrown here kills the whole Host process. Every branch is
517
+ * therefore contained, and the caller adds one more net.
518
+ */
519
+ let watchdogSpoken = false;
520
+
521
+ async function watchFirstClientBoot(startedAt) {
522
+ try {
523
+ // Several applies can share one process (hot swap); only the first one speaks.
524
+ if (watchdogSpoken) return;
525
+ watchdogSpoken = true;
526
+ let text = '';
527
+ try {
528
+ text = await fs.readFile(CLIENT_LOG, 'utf8');
529
+ } catch (error) {
530
+ if (!error || error.code !== 'ENOENT') throw error;
531
+ }
532
+ const since = startedAt - 4_000;
533
+ let newest = 0;
534
+ let reported = false;
535
+ for (const line of text.split(String.fromCharCode(10))) {
536
+ if (!line.includes('"kind":"boot"')) continue;
537
+ const stamp = Date.parse(line.slice(0, line.indexOf(' ')));
538
+ if (!Number.isFinite(stamp)) continue;
539
+ if (stamp > newest) newest = stamp;
540
+ if (stamp >= since) reported = true;
541
+ }
542
+ if (reported) return;
543
+ // A page that reported shortly before this apply is an already-running page:
544
+ // it did load this plugin, and a Host hot swap (which happens on every edit
545
+ // while developing) must not be reported as "the window never got the code".
546
+ if (newest >= startedAt - 120_000) return;
547
+ logHost(
548
+ 'client half did not report within 12s: the window is running a plugin list from before this code.',
549
+ );
550
+ // The page will not ask for a script that is not in its frozen list, but it
551
+ // does watch this package's files: one byte-level change to the Client entry
552
+ // makes the loader hand the module to the live page. Flip the trailing
553
+ // newline, so the file stays byte-stable instead of growing on every launch.
554
+ if (!NUDGE_ENABLED) {
555
+ logHost('recovery nudge disabled by DPC_NUDGE=off; the entry stays hidden until the page reloads');
556
+ return;
557
+ }
558
+ try {
559
+ const source = await fs.readFile(CLIENT_FILE, 'utf8');
560
+ const next = source.endsWith(NL + NL) ? source.slice(0, -1) : source + NL;
561
+ await fs.writeFile(CLIENT_FILE, next, 'utf8');
562
+ recoveredAt = Date.now();
563
+ logHost(
564
+ `recovery nudge: rewrote ${path.basename(CLIENT_FILE)} (${next.length - source.length} byte) so the ` +
565
+ 'loader hands this plugin to the running page; the sidebar entry should appear within a second',
566
+ );
567
+ } catch (error) {
568
+ logHost(`recovery nudge failed: ${describeError(error)}`);
569
+ }
570
+ } catch (error) {
571
+ logHost(`client boot watchdog failed: ${describeError(error)}`);
572
+ }
573
+ }
574
+
575
+ /** Register the route; returns null while `connection` is not available yet so
576
+ * that the caller can retry on a later tick. */
577
+ function mount(scope) {
578
+ const connection = serviceOf(scope, 'connection');
579
+ if (!connection || !connection.fetch || typeof connection.fetch.register !== 'function') return null;
580
+ return connection.fetch.register({
581
+ path: ROUTE_PATH,
582
+ methods: ['POST'],
583
+ requestBody: 'buffered',
584
+ fetch: (request) => handle(scope, request),
585
+ });
586
+ }
587
+
588
+ export function apply(ctx) {
589
+ let disposed = false;
590
+ let dispose = null;
591
+ let retry = null;
592
+ let attempts = 0;
593
+
594
+ logHost('apply: host half starting');
595
+
596
+ const attach = (scope) => {
597
+ if (disposed || dispose) return;
598
+ // A hot swap replaces the services themselves: a reference cached from the
599
+ // previous apply would answer with the old graph.
600
+ clientModules = null;
601
+ attempts += 1;
602
+ try {
603
+ dispose = mount(scope);
604
+ } catch (error) {
605
+ console.error(`[${PACKAGE}] failed to register ${ROUTE_PATH}`, error);
606
+ logHost(`mount failed: ${describeError(error)}`);
607
+ }
608
+ if (dispose) {
609
+ logHost(`route mounted at ${ROUTE_PATH} (attempt ${attempts})`);
610
+ const startedAt = Date.now();
611
+ // A timer callback that throws is fatal for the Host: contain it here and
612
+ // again inside the async body.
613
+ const watchdog = setTimeout(() => {
614
+ void watchFirstClientBoot(startedAt).catch((error) =>
615
+ logHost(`client boot watchdog failed: ${describeError(error)}`),
616
+ );
617
+ }, 12_000);
618
+ if (typeof watchdog.unref === 'function') watchdog.unref();
619
+ return;
620
+ }
621
+ if (!disposed) retry = setTimeout(() => attach(scope), 1000);
622
+ };
623
+
624
+ try {
625
+ if (typeof ctx.inject === 'function') ctx.inject(['connection'], attach);
626
+ else attach(ctx);
627
+ } catch (error) {
628
+ console.error(`[${PACKAGE}] host half could not start`, error);
629
+ }
630
+
631
+ // Cordis calls this on unload: drop the route and any pending retry.
632
+ if (typeof ctx.effect === 'function') {
633
+ ctx.effect(
634
+ () => () => {
635
+ disposed = true;
636
+ if (retry) clearTimeout(retry);
637
+ retry = null;
638
+ if (typeof dispose === 'function') {
639
+ try {
640
+ dispose();
641
+ } catch {
642
+ /* the Host already tore the route down */
643
+ }
644
+ }
645
+ dispose = null;
646
+ },
647
+ `${PACKAGE}: classification route`,
648
+ );
649
+ }
650
+ }