dsh-panel-control 0.3.14 → 0.3.15

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 CHANGED
@@ -1,706 +1,804 @@
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
- /**
48
- * The nudge is opt-in, and that default is deliberate.
49
- *
50
- * Rewriting `client.js` makes the loader hot-swap this module into the live
51
- * page. That re-evaluation runs the whole slot reconcile again, and a plugin
52
- * whose registration is not idempotent then throws on its second
53
- * `register({ id: 'workspace-control' })` and loses its sidebar entry: healing
54
- * this plugin broke a neighbour's. A page reload fixes the frozen list without
55
- * touching anyone else, so the safe path is the banner, and the nudge stays
56
- * behind DPC_NUDGE=on for people who need it and accept the risk.
57
- */
58
- const NUDGE_ENABLED = ['on', '1', 'true', 'yes'].includes(
59
- String(process.env.DPC_NUDGE || '').trim().toLowerCase(),
60
- );
61
-
62
- /** Set once the nudge below actually rewrote the Client file in this process. */
63
- let recoveredAt = 0;
64
-
65
- /** `String.fromCharCode(10)`: a literal newline in source is easy to mangle. */
66
- const NL = String.fromCharCode(10);
67
-
68
- /** Identifies one Host process, so a log spanning several launches shows which
69
- * apply/mount lines belong together. */
70
- const BOOT = Math.random().toString(36).slice(2, 8);
71
-
72
- /** Route limits: this endpoint spends model tokens, so only local callers and
73
- * only a small body are accepted. */
74
- const MAX_BODY_BYTES = 64 * 1024;
75
- const MAX_ENTRIES = 64;
76
- const MAX_GROUPS = 4;
77
- const MAX_OUTPUT_TOKENS = 700;
78
- const TIMEOUT_MS = 45_000;
79
-
80
- /* ------------------------------------------------------------------ helpers */
81
-
82
- function isRecord(value) {
83
- return value !== null && typeof value === 'object' && !Array.isArray(value);
84
- }
85
-
86
- function isLoopbackAuthority(authority) {
87
- if (typeof authority !== 'string' || authority === '') return false;
88
- try {
89
- const url = new URL(`http://${authority}`);
90
- if (url.username || url.password || url.pathname !== '/' || url.search || url.hash) return false;
91
- const hostname = url.hostname.replace(/\.$/, '');
92
- return hostname === 'localhost' || hostname === '::1' || hostname === '[::1]' || /^127\.\d+\.\d+\.\d+$/.test(hostname);
93
- } catch {
94
- return false;
95
- }
96
- }
97
-
98
- /**
99
- * Only the page the Host itself served may call this route.
100
- *
101
- * "Host and Origin are both loopback" is not enough: every dev server, every
102
- * other app's webview and every local playground on this machine is also
103
- * loopback, so that check let them rewrite the user's layout and spend model
104
- * tokens. The discriminator is the port: the page is same-origin only when its
105
- * origin carries the very authority this request arrived on.
106
- */
107
- function isOwnRequest(request) {
108
- const host = request.headers.get('host');
109
- if (!isLoopbackAuthority(host)) return false;
110
- // A browser labels where the request came from; anything but same-origin (or
111
- // a non-browser caller) is a different page and has no business here.
112
- const site = request.headers.get('sec-fetch-site');
113
- if (site !== null && site !== 'same-origin' && site !== 'none') return false;
114
- const origin = request.headers.get('origin');
115
- if (origin === null) return true;
116
- try {
117
- const url = new URL(origin);
118
- if (url.host === host) return true;
119
- // The desktop shell serves the page from a custom protocol: that page is
120
- // still the Host's own, even though its origin is not an http authority.
121
- return url.protocol === 'dsh-app:' || url.protocol === 'file:';
122
- } catch {
123
- return false;
124
- }
125
- }
126
-
127
- function reply(rpcId, result) {
128
- // Connection's client requires `error.details`, even when empty.
129
- const value =
130
- result && result.ok === false
131
- ? { ...result, error: { ...result.error, details: (result.error && result.error.details) || {} } }
132
- : result;
133
- return Response.json({ type: 'server-response', rpcId, result: value });
134
- }
135
-
136
- function refusal(code, message) {
137
- return { ok: false, error: { code, message } };
138
- }
139
-
140
- function describeError(error) {
141
- if (error && typeof error.message === 'string' && error.message) return error.message;
142
- try {
143
- return JSON.stringify(error);
144
- } catch {
145
- return String(error);
146
- }
147
- }
148
-
149
- /* --------------------------------------------------------------- local state */
150
-
151
- async function ensureStateDir() {
152
- await fs.mkdir(STATE_DIR, { recursive: true });
153
- }
154
-
155
- async function appendLine(file, line) {
156
- try {
157
- await ensureStateDir();
158
- await fs.appendFile(file, `${new Date().toISOString()} [${BOOT}] ${line}\n`, 'utf8');
159
- const stat = await fs.stat(file);
160
- if (stat.size > LOG_KEEP_BYTES) {
161
- const text = await fs.readFile(file, 'utf8');
162
- await fs.writeFile(file, text.slice(-Math.floor(LOG_KEEP_BYTES / 2)), 'utf8');
163
- }
164
- } catch {
165
- /* Logging is a convenience: it must never break a request. */
166
- }
167
- }
168
-
169
- const logHost = (line) => {
170
- void appendLine(HOST_LOG, line);
171
- };
172
-
173
- const logClient = (line) => {
174
- void appendLine(CLIENT_LOG, line);
175
- };
176
-
177
- async function readPrefsFile() {
178
- try {
179
- const text = await fs.readFile(PREFS_FILE, 'utf8');
180
- const parsed = JSON.parse(text);
181
- return isRecord(parsed) ? parsed : null;
182
- } catch (error) {
183
- if (error && error.code === 'ENOENT') return null;
184
- await logHost(`prefs read failed: ${describeError(error)}`);
185
- return null;
186
- }
187
- }
188
-
189
- async function writePrefsFileNow(prefs) {
190
- if (!isRecord(prefs)) throw new Error('preferences must be an object');
191
- const text = JSON.stringify(prefs);
192
- if (text.length > MAX_PREFS_BYTES) throw new Error('preferences are too large');
193
- await ensureStateDir();
194
- const temporary = `${PREFS_FILE}.tmp`;
195
- await fs.writeFile(temporary, text, 'utf8');
196
- await fs.rename(temporary, PREFS_FILE);
197
- return text.length;
198
- }
199
-
200
- /**
201
- * One writer at a time.
202
- *
203
- * Two windows (or a retry racing the debounce) otherwise both write the same
204
- * `.tmp` path: one rename wins, the other fails with EPERM/EEXIST and that save
205
- * is lost even though the caller was told nothing. A promise chain costs nothing
206
- * here -- saves are rare and small -- and keeps the last write the one on disk.
207
- */
208
- let writeChain = Promise.resolve();
209
-
210
- function writePrefsFile(prefs) {
211
- const next = writeChain.then(
212
- () => writePrefsFileNow(prefs),
213
- () => writePrefsFileNow(prefs),
214
- );
215
- // Keep the chain alive after a rejection: the caller still gets the error.
216
- writeChain = next.catch(() => {});
217
- return next;
218
- }
219
-
220
- /* ------------------------------------------------------------------- prompt */
221
-
222
- /**
223
- * One prompt, no chat history: the model answers a single question about a
224
- * short list. Everything it needs is inside the message, and the reply is
225
- * parsed defensively because a model can always answer with prose.
226
- */
227
- function buildPrompt(draft) {
228
- const language = draft.locale === 'zh' ? '中文' : 'English';
229
- const groupNames = (draft.groups || []).map((group) => group.name).filter(Boolean);
230
- const entries = draft.entries.map((entry) => ({ id: entry.id, label: entry.label }));
231
- return [
232
- `You are filing the entry list of a developer tool's sidebar into a few named sections.`,
233
- `Answer in ${language}.`,
234
- ``,
235
- `Existing section names (reuse them when they fit; you may add new ones):`,
236
- groupNames.length ? JSON.stringify(groupNames) : '[] (none yet)',
237
- ``,
238
- `Entries to file, as JSON:`,
239
- JSON.stringify(entries),
240
- ``,
241
- `Rules:`,
242
- `- Produce 2 to ${MAX_GROUPS} sections; every entry appears in exactly one section.`,
243
- `- Section names are short: 2-4 Chinese characters, or 1-3 English words.`,
244
- `- Group by what the entries are for, not by their order.`,
245
- `- Answer with a single JSON object and nothing else: {"groups":[{"name":"...","ids":["..."]}]}`,
246
- `- No prose, no markdown fence, no comments.`,
247
- ].join('\n');
248
- }
249
-
250
- /** Pull the first JSON object out of a model reply (tolerates a code fence). */
251
- function extractJson(text) {
252
- const raw = typeof text === 'string' ? text : '';
253
- const start = raw.indexOf('{');
254
- const end = raw.lastIndexOf('}');
255
- if (start < 0 || end <= start) return null;
256
- try {
257
- return JSON.parse(raw.slice(start, end + 1));
258
- } catch {
259
- return null;
260
- }
261
- }
262
-
263
- /** Keep only the answer this caller can use: known ids, at most MAX_GROUPS
264
- * sections, short names, no duplicates. */
265
- function sanitizeAnswer(answer, draft) {
266
- if (!isRecord(answer) || !Array.isArray(answer.groups)) return null;
267
- const known = new Set(draft.entries.map((entry) => entry.id));
268
- const used = new Set();
269
- const groups = [];
270
- for (const group of answer.groups) {
271
- if (!isRecord(group) || !Array.isArray(group.ids)) continue;
272
- const name = typeof group.name === 'string' ? group.name.trim().slice(0, 24) : '';
273
- const ids = [];
274
- for (const id of group.ids) {
275
- if (typeof id !== 'string' || !known.has(id) || used.has(id)) continue;
276
- used.add(id);
277
- ids.push(id);
278
- }
279
- if (ids.length === 0) continue;
280
- groups.push({ name: name || `#${groups.length + 1}`, ids });
281
- if (groups.length >= MAX_GROUPS) break;
282
- }
283
- if (groups.length === 0) return null;
284
- return { groups };
285
- }
286
-
287
- /* --------------------------------------------------------------------- call */
288
-
289
- function serviceOf(scope, key) {
290
- try {
291
- if (scope && typeof scope.get === 'function') {
292
- const value = scope.get(key);
293
- if (value !== undefined && value !== null) return value;
294
- }
295
- } catch {
296
- /* a service that is not available yet reads as missing, not as a crash */
297
- }
298
- try {
299
- return scope ? scope[key] : undefined;
300
- } catch {
301
- return undefined;
302
- }
303
- }
304
-
305
- function currentModel(scope) {
306
- const defaults = serviceOf(scope, 'agentDefaultModel');
307
- if (defaults && typeof defaults.currentSelection === 'function') {
308
- try {
309
- const selection = defaults.currentSelection();
310
- if (selection && typeof selection.provider === 'string' && typeof selection.model === 'string') {
311
- return selection;
312
- }
313
- } catch {
314
- /* fall through: the LLM service may still know a default */
315
- }
316
- }
317
- return null;
318
- }
319
-
320
- /** One non-streaming completion: the joined text of a single stream. */
321
- async function complete(scope, prompt, signal) {
322
- const llm = serviceOf(scope, 'llm');
323
- if (!llm || typeof llm !== 'object') throw new Error('the Host LLM service is not available');
324
- const selection = currentModel(scope);
325
- if (!selection) throw new Error('no default model is configured for this profile');
326
-
327
- const messages = [{ role: 'user', content: [{ type: 'text', text: prompt }] }];
328
- const request = {
329
- provider: selection.provider,
330
- model: selection.model,
331
- messages,
332
- maxTokens: MAX_OUTPUT_TOKENS,
333
- };
334
- // Filing labels needs no deliberation, and a thinking budget would eat the
335
- // whole output allowance before a single character of the answer is emitted.
336
- request.reasoningEffort = 'off';
337
-
338
- let text = '';
339
- let finish = null;
340
- const seen = new Set();
341
- const consume = async (stream) => {
342
- for await (const chunk of stream) {
343
- if (!chunk || typeof chunk !== 'object') continue;
344
- seen.add(String(chunk.type));
345
- if (chunk.type === 'text-delta' && typeof chunk.text === 'string') text += chunk.text;
346
- else if (chunk.type === 'finish') finish = chunk;
347
- }
348
- };
349
-
350
- // `prepareCall` validates the route and freezes the resolved config; the
351
- // prepared stream only accepts exactly that config, so hand it back as-is.
352
- let prepared = null;
353
- if (typeof llm.prepareCall === 'function') prepared = await llm.prepareCall(request, signal);
354
- if (prepared && typeof prepared.stream === 'function') await consume(prepared.stream(prepared.config));
355
- else await consume(llm.stream(request));
356
-
357
- if (finish && isRecord(finish.reason)) {
358
- const kind = finish.reason.kind;
359
- if (kind && kind !== 'stop' && kind !== 'max-tokens') throw new Error(`the model stopped early (${kind})`);
360
- }
361
- return { text, model: `${selection.provider}/${selection.model}`, seen: Array.from(seen).join('+') };
362
- }
363
-
364
- /* ------------------------------------------------------------------ handler */
365
-
366
- async function classify(scope, payload, signal) {
367
- if (!isRecord(payload)) return refusal('bad-request', 'The request payload is not an object.');
368
- const entries = [];
369
- for (const entry of (Array.isArray(payload.entries) ? payload.entries : []).slice(0, MAX_ENTRIES)) {
370
- if (!isRecord(entry) || typeof entry.id !== 'string' || entry.id === '') continue;
371
- entries.push({
372
- id: entry.id,
373
- label: typeof entry.label === 'string' && entry.label !== '' ? entry.label.slice(0, 80) : entry.id,
374
- });
375
- }
376
- if (entries.length === 0) return refusal('bad-request', 'There is nothing to file: the entry list is empty.');
377
-
378
- const groups = [];
379
- for (const group of (Array.isArray(payload.groups) ? payload.groups : []).slice(0, MAX_GROUPS)) {
380
- if (!isRecord(group) || typeof group.name !== 'string' || group.name.trim() === '') continue;
381
- groups.push({ name: group.name.trim().slice(0, 24) });
382
- }
383
-
384
- const draft = { entries, groups, locale: payload.locale === 'zh' ? 'zh' : 'en' };
385
-
386
- const deadline = new AbortController();
387
- const timer = setTimeout(() => deadline.abort(), TIMEOUT_MS);
388
- const mergedSignal = () => {
389
- if (!signal) return deadline.signal;
390
- if (typeof AbortSignal !== 'undefined' && typeof AbortSignal.any === 'function') {
391
- return AbortSignal.any([signal, deadline.signal]);
392
- }
393
- return deadline.signal;
394
- };
395
-
396
- try {
397
- const answer = await complete(scope, buildPrompt(draft), mergedSignal());
398
- const parsed = sanitizeAnswer(extractJson(answer.text), draft);
399
- if (!parsed) {
400
- logHost(`classify: unusable answer (${answer.seen}, ${answer.text.length} chars)`);
401
- return refusal('unparsable-answer', 'The model did not answer with usable JSON.');
402
- }
403
- const filed = new Set(parsed.groups.flatMap((group) => group.ids));
404
- logHost(`classify: ${parsed.groups.length} groups over ${filed.size}/${entries.length} entries (${answer.model})`);
405
- return {
406
- ok: true,
407
- value: {
408
- groups: parsed.groups,
409
- unfiled: entries.filter((entry) => !filed.has(entry.id)).map((entry) => entry.id),
410
- model: answer.model,
411
- },
412
- };
413
- } catch (error) {
414
- const aborted = deadline.signal.aborted || (signal && signal.aborted);
415
- return refusal(aborted ? 'timeout' : 'model-failed', describeError(error));
416
- } finally {
417
- clearTimeout(timer);
418
- }
419
- }
420
-
421
- /* -------------------------------------------------------------------- route */
422
-
423
- async function handle(scope, request) {
424
- if (!isOwnRequest(request)) return new Response('forbidden', { status: 403 });
425
- if (request.method !== 'POST') return new Response('method not allowed', { status: 405 });
426
- const contentType = request.headers.get('content-type');
427
- if (!contentType || contentType.split(';', 1)[0].trim().toLowerCase() !== 'application/json') {
428
- return new Response('content type must be application/json', { status: 415 });
429
- }
430
-
431
- // Read the text first: `request.json()` would parse the whole body before the
432
- // size check could refuse it, which is exactly what the limit exists to stop.
433
- let raw;
434
- try {
435
- raw = await request.text();
436
- } catch {
437
- return new Response('body is unreadable', { status: 400 });
438
- }
439
- if (raw.length > MAX_BODY_BYTES) return new Response('body too large', { status: 413 });
440
- let message;
441
- try {
442
- message = JSON.parse(raw);
443
- } catch {
444
- return new Response('body is not JSON', { status: 400 });
445
- }
446
-
447
- const rpcId = isRecord(message) && typeof message.rpcId === 'string' ? message.rpcId : 'invalid-request';
448
- if (
449
- !isRecord(message) ||
450
- message.type !== 'client-request' ||
451
- typeof message.rpcId !== 'string' ||
452
- message.method !== ENDPOINT ||
453
- !isRecord(message.payload)
454
- ) {
455
- return reply(rpcId, refusal('gateway/bad-request', 'This is not a panel-control request.'));
456
- }
457
- const call = message.payload;
458
- const method = typeof call.method === 'string' ? call.method : '';
459
-
460
- try {
461
- if (method === 'classify') return reply(rpcId, await classify(scope, call.payload, request.signal));
462
- if (method === 'load') return reply(rpcId, await loadPreferences());
463
- if (method === 'save') return reply(rpcId, await savePreferences(call.payload));
464
- if (method === 'inventory') return reply(rpcId, inventorySnapshot(scope));
465
- if (method === 'report') {
466
- const detail = isRecord(call.payload) ? call.payload : {};
467
- const text = JSON.stringify(detail).slice(0, 4_000);
468
- logClient(`${typeof detail.kind === 'string' ? detail.kind : 'report'} ${text}`);
469
- return reply(rpcId, { ok: true, value: { logged: true } });
470
- }
471
- return reply(rpcId, refusal('unknown-method', `Unknown method: ${String(call.method)}`));
472
- } catch (error) {
473
- logHost(`request ${method || '?'} failed: ${describeError(error)}`);
474
- return reply(rpcId, refusal('handler-failed', describeError(error)));
475
- }
476
- }
477
-
478
- async function loadPreferences() {
479
- const prefs = await readPrefsFile();
480
- return { ok: true, value: { prefs, file: PREFS_FILE } };
481
- }
482
-
483
- async function savePreferences(payload) {
484
- if (!isRecord(payload) || !isRecord(payload.prefs)) {
485
- return refusal('bad-request', 'A preferences object is required.');
486
- }
487
- const bytes = await writePrefsFile(payload.prefs);
488
- return { ok: true, value: { saved: true, bytes } };
489
- }
490
-
491
- /**
492
- * What the Host is serving to pages right now, taken from the same module graph
493
- * that builds `window.__DSH_BOOT__`. Comparing revisions is the only reliable
494
- * way to tell "this page booted with an older plugin list": the page cannot
495
- * read the Host's list, and the profile manifest says nothing about which
496
- * plugins actually made it into the served graph.
497
- */
498
- let clientModules = null;
499
-
500
- function inventorySnapshot(scope) {
501
- if (!clientModules) clientModules = serviceOf(scope, 'clientModules');
502
- const graph = clientModules && typeof clientModules.graph === 'function' ? clientModules.graph() : null;
503
- if (!graph || typeof graph.rev !== 'string') return { ok: false, error: { message: 'The plugin graph is not readable.' } };
504
- const entries = Array.isArray(graph.entries) ? graph.entries : [];
505
- return {
506
- ok: true,
507
- value: {
508
- rev: graph.rev,
509
- release: typeof graph.release === 'string' ? graph.release : null,
510
- ids: entries.map((entry) => String(entry && entry.id)).filter(Boolean),
511
- // True when this process had to hand itself to the page: the page's list is
512
- // still stale, but the entry it lacks is this very plugin, so there is
513
- // nothing left for the user to do.
514
- recovered: recoveredAt > 0,
515
- },
516
- };
517
- }
518
-
519
- /**
520
- * Write one line per apply that answers the only question a cold start leaves
521
- * open: did this package even reach the Host's plugin graph?
522
- *
523
- * The page can only load what the graph advertises, so "missing from the list"
524
- * and "listed but never fetched" have completely different fixes. One line,
525
- * once per apply, settles it from `host.log` alone.
526
- */
527
- function logInventoryOnApply(scope) {
528
- try {
529
- const snapshot = inventorySnapshot(scope);
530
- if (!snapshot.ok) {
531
- logHost(`graph: unreadable (${String(snapshot.error && snapshot.error.message)})`);
532
- return;
533
- }
534
- const value = snapshot.value;
535
- const listed = value.ids.includes(PACKAGE);
536
- logHost(
537
- `graph: ${listed ? 'lists' : 'does not list'} ${PACKAGE} · rev ${value.rev} · ` +
538
- `${value.ids.length} client entr${value.ids.length === 1 ? 'y' : 'ies'}`,
539
- );
540
- } catch (error) {
541
- logHost(`graph: probe failed (${describeError(error)})`);
542
- }
543
- }
544
-
545
- /**
546
- * Say it out loud when the page never came back.
547
- *
548
- * The Host is up (this half ran) and yet the Client half reports nothing: the
549
- * window was built from a plugin list that no longer matches the code on disk,
550
- * so its script request went nowhere and the page silently omits the entry. The
551
- * user sees a plugin that "does not start", so write the diagnosis where it can
552
- * be found -- once per launch.
553
- *
554
- * This runs in a timer, outside every request and outside Cordis' own error
555
- * handling: anything thrown here kills the whole Host process. Every branch is
556
- * therefore contained, and the caller adds one more net.
557
- */
558
- let watchdogSpoken = false;
559
-
560
- async function watchFirstClientBoot(startedAt) {
561
- try {
562
- // Several applies can share one process (hot swap); only the first one speaks.
563
- if (watchdogSpoken) return;
564
- watchdogSpoken = true;
565
- let text = '';
566
- try {
567
- text = await fs.readFile(CLIENT_LOG, 'utf8');
568
- } catch (error) {
569
- if (!error || error.code !== 'ENOENT') throw error;
570
- }
571
- const since = startedAt - 4_000;
572
- let newest = 0;
573
- let reported = false;
574
- for (const line of text.split(String.fromCharCode(10))) {
575
- if (!line.includes('"kind":"boot"')) continue;
576
- const stamp = Date.parse(line.slice(0, line.indexOf(' ')));
577
- if (!Number.isFinite(stamp)) continue;
578
- if (stamp > newest) newest = stamp;
579
- if (stamp >= since) reported = true;
580
- }
581
- if (reported) return;
582
- // A page that reported shortly before this apply is an already-running page:
583
- // it did load this plugin, and a Host hot swap (which happens on every edit
584
- // while developing) must not be reported as "the window never got the code".
585
- if (newest >= startedAt - 120_000) return;
586
- logHost(
587
- 'client half did not report within 30s: the window is running a plugin list from before this code ' +
588
- '(a rebuilt interface can take this long to finish loading; press the refresh button if the entry is missing)',
589
- );
590
- // The page will not ask for a script that is not in its frozen list, but it
591
- // does watch this package's files: one byte-level change to the Client entry
592
- // makes the loader hand the module to the live page. Flip the trailing
593
- // newline, so the file stays byte-stable instead of growing on every launch.
594
- if (!NUDGE_ENABLED) {
595
- logHost('recovery nudge stayed off (opt in with DPC_NUDGE=on); a page reload is the safe fix');
596
- return;
597
- }
598
- try {
599
- // The page may have reported while this timer was waiting: never rewrite a
600
- // file the running page just loaded, because the resulting hot swap is
601
- // what re-runs everybody's registration.
602
- const late = await fs.readFile(CLIENT_LOG, 'utf8').catch(() => '');
603
- const quiet = late
604
- .split(String.fromCharCode(10))
605
- .some((line) => {
606
- if (!line.includes('"kind":"boot"')) return false;
607
- const stamp = Date.parse(line.slice(0, line.indexOf(' ')));
608
- return Number.isFinite(stamp) && stamp >= since;
609
- });
610
- if (quiet) {
611
- logHost('recovery nudge skipped: the page reported while the watchdog was waiting');
612
- return;
613
- }
614
- const source = await fs.readFile(CLIENT_FILE, 'utf8');
615
- const next = source.endsWith(NL + NL) ? source.slice(0, -1) : source + NL;
616
- await fs.writeFile(CLIENT_FILE, next, 'utf8');
617
- recoveredAt = Date.now();
618
- logHost(
619
- `recovery nudge: rewrote ${path.basename(CLIENT_FILE)} (${next.length - source.length} byte) so the ` +
620
- 'loader hands this plugin to the running page; the sidebar entry should appear within a second',
621
- );
622
- } catch (error) {
623
- logHost(`recovery nudge failed: ${describeError(error)}`);
624
- }
625
- } catch (error) {
626
- logHost(`client boot watchdog failed: ${describeError(error)}`);
627
- }
628
- }
629
-
630
- /** Register the route; returns null while `connection` is not available yet so
631
- * that the caller can retry on a later tick. */
632
- function mount(scope) {
633
- const connection = serviceOf(scope, 'connection');
634
- if (!connection || !connection.fetch || typeof connection.fetch.register !== 'function') return null;
635
- return connection.fetch.register({
636
- path: ROUTE_PATH,
637
- methods: ['POST'],
638
- requestBody: 'buffered',
639
- fetch: (request) => handle(scope, request),
640
- });
641
- }
642
-
643
- export function apply(ctx) {
644
- let disposed = false;
645
- let dispose = null;
646
- let retry = null;
647
- let attempts = 0;
648
-
649
- logHost('apply: host half starting');
650
-
651
- const attach = (scope) => {
652
- if (disposed || dispose) return;
653
- // A hot swap replaces the services themselves: a reference cached from the
654
- // previous apply would answer with the old graph.
655
- clientModules = null;
656
- attempts += 1;
657
- try {
658
- dispose = mount(scope);
659
- } catch (error) {
660
- console.error(`[${PACKAGE}] failed to register ${ROUTE_PATH}`, error);
661
- logHost(`mount failed: ${describeError(error)}`);
662
- }
663
- if (dispose) {
664
- logHost(`route mounted at ${ROUTE_PATH} (attempt ${attempts})`);
665
- logInventoryOnApply(scope);
666
- const startedAt = Date.now();
667
- // A timer callback that throws is fatal for the Host: contain it here and
668
- // again inside the async body.
669
- const watchdog = setTimeout(() => {
670
- void watchFirstClientBoot(startedAt).catch((error) =>
671
- logHost(`client boot watchdog failed: ${describeError(error)}`),
672
- );
673
- }, 30_000);
674
- if (typeof watchdog.unref === 'function') watchdog.unref();
675
- return;
676
- }
677
- if (!disposed) retry = setTimeout(() => attach(scope), 1000);
678
- };
679
-
680
- try {
681
- if (typeof ctx.inject === 'function') ctx.inject(['connection'], attach);
682
- else attach(ctx);
683
- } catch (error) {
684
- console.error(`[${PACKAGE}] host half could not start`, error);
685
- }
686
-
687
- // Cordis calls this on unload: drop the route and any pending retry.
688
- if (typeof ctx.effect === 'function') {
689
- ctx.effect(
690
- () => () => {
691
- disposed = true;
692
- if (retry) clearTimeout(retry);
693
- retry = null;
694
- if (typeof dispose === 'function') {
695
- try {
696
- dispose();
697
- } catch {
698
- /* the Host already tore the route down */
699
- }
700
- }
701
- dispose = null;
702
- },
703
- `${PACKAGE}: classification route`,
704
- );
705
- }
706
- }
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
+ // Tests (and anyone sandboxing this plugin) point DPC_STATE_DIR somewhere else;
37
+ // the smoke harness writes here, and it must not touch the real preferences or
38
+ // the real log that support questions get asked about.
39
+ const STATE_DIR = process.env.DPC_STATE_DIR
40
+ ? path.resolve(process.env.DPC_STATE_DIR)
41
+ : path.join(os.homedir(), '.dsh', 'panel-control');
42
+ const PREFS_FILE = path.join(STATE_DIR, 'prefs.json');
43
+ const HOST_LOG = path.join(STATE_DIR, 'host.log');
44
+ const CLIENT_LOG = path.join(STATE_DIR, 'client.log');
45
+ const MAX_PREFS_BYTES = 256 * 1024;
46
+ const LOG_KEEP_BYTES = 256 * 1024;
47
+
48
+ /** The Client half this Host half ships with, used by the recovery nudge below. */
49
+ const CLIENT_FILE = fileURLToPath(new URL('./client.js', import.meta.url));
50
+
51
+ /** Set `DPC_NUDGE=off` to keep the Host strictly read-only about its own files. */
52
+ /**
53
+ * The nudge is opt-in, and that default is deliberate.
54
+ *
55
+ * Rewriting `client.js` makes the loader hot-swap this module into the live
56
+ * page. That re-evaluation runs the whole slot reconcile again, and a plugin
57
+ * whose registration is not idempotent then throws on its second
58
+ * `register({ id: 'workspace-control' })` and loses its sidebar entry: healing
59
+ * this plugin broke a neighbour's. A page reload fixes the frozen list without
60
+ * touching anyone else, so the safe path is the banner, and the nudge stays
61
+ * behind DPC_NUDGE=on for people who need it and accept the risk.
62
+ */
63
+ const NUDGE_ENABLED = ['on', '1', 'true', 'yes'].includes(
64
+ String(process.env.DPC_NUDGE || '').trim().toLowerCase(),
65
+ );
66
+
67
+ /** Set once the nudge below actually rewrote the Client file in this process. */
68
+ let recoveredAt = 0;
69
+
70
+ /** `String.fromCharCode(10)`: a literal newline in source is easy to mangle. */
71
+ const NL = String.fromCharCode(10);
72
+
73
+ /** Identifies one Host process, so a log spanning several launches shows which
74
+ * apply/mount lines belong together. */
75
+ const BOOT = Math.random().toString(36).slice(2, 8);
76
+
77
+ /** Route limits: this endpoint spends model tokens, so only local callers and
78
+ * only a small body are accepted. */
79
+ const MAX_BODY_BYTES = 64 * 1024;
80
+ const MAX_ENTRIES = 64;
81
+ const MAX_GROUPS = 4;
82
+ const MAX_OUTPUT_TOKENS = 700;
83
+ const TIMEOUT_MS = 45_000;
84
+
85
+ /* ------------------------------------------------------------------ helpers */
86
+
87
+ function isRecord(value) {
88
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
89
+ }
90
+
91
+ function isLoopbackAuthority(authority) {
92
+ if (typeof authority !== 'string' || authority === '') return false;
93
+ try {
94
+ const url = new URL(`http://${authority}`);
95
+ if (url.username || url.password || url.pathname !== '/' || url.search || url.hash) return false;
96
+ const hostname = url.hostname.replace(/\.$/, '');
97
+ return hostname === 'localhost' || hostname === '::1' || hostname === '[::1]' || /^127\.\d+\.\d+\.\d+$/.test(hostname);
98
+ } catch {
99
+ return false;
100
+ }
101
+ }
102
+
103
+ /**
104
+ * Only the page the Host itself served may call this route.
105
+ *
106
+ * "Host and Origin are both loopback" is not enough: every dev server, every
107
+ * other app's webview and every local playground on this machine is also
108
+ * loopback, so that check let them rewrite the user's layout and spend model
109
+ * tokens. The discriminator is the port: the page is same-origin only when its
110
+ * origin carries the very authority this request arrived on.
111
+ */
112
+ function isOwnRequest(request) {
113
+ const host = request.headers.get('host');
114
+ if (!isLoopbackAuthority(host)) return false;
115
+ // A browser labels where the request came from; anything but same-origin (or
116
+ // a non-browser caller) is a different page and has no business here.
117
+ const site = request.headers.get('sec-fetch-site');
118
+ if (site !== null && site !== 'same-origin' && site !== 'none') return false;
119
+ const origin = request.headers.get('origin');
120
+ if (origin === null) return true;
121
+ try {
122
+ const url = new URL(origin);
123
+ if (url.host === host) return true;
124
+ // The desktop shell serves the page from a custom protocol: that page is
125
+ // still the Host's own, even though its origin is not an http authority.
126
+ return url.protocol === 'dsh-app:' || url.protocol === 'file:';
127
+ } catch {
128
+ return false;
129
+ }
130
+ }
131
+
132
+ function reply(rpcId, result) {
133
+ // Connection's client requires `error.details`, even when empty.
134
+ const value =
135
+ result && result.ok === false
136
+ ? { ...result, error: { ...result.error, details: (result.error && result.error.details) || {} } }
137
+ : result;
138
+ return Response.json({ type: 'server-response', rpcId, result: value });
139
+ }
140
+
141
+ function refusal(code, message) {
142
+ return { ok: false, error: { code, message } };
143
+ }
144
+
145
+ function describeError(error) {
146
+ if (error && typeof error.message === 'string' && error.message) return error.message;
147
+ try {
148
+ return JSON.stringify(error);
149
+ } catch {
150
+ return String(error);
151
+ }
152
+ }
153
+
154
+ /* --------------------------------------------------------------- local state */
155
+
156
+ async function ensureStateDir() {
157
+ await fs.mkdir(STATE_DIR, { recursive: true });
158
+ }
159
+
160
+ async function appendLine(file, line) {
161
+ try {
162
+ await ensureStateDir();
163
+ await fs.appendFile(file, `${new Date().toISOString()} [${BOOT}] ${line}\n`, 'utf8');
164
+ const stat = await fs.stat(file);
165
+ if (stat.size > LOG_KEEP_BYTES) {
166
+ const text = await fs.readFile(file, 'utf8');
167
+ await fs.writeFile(file, text.slice(-Math.floor(LOG_KEEP_BYTES / 2)), 'utf8');
168
+ }
169
+ } catch {
170
+ /* Logging is a convenience: it must never break a request. */
171
+ }
172
+ }
173
+
174
+ const logHost = (line) => {
175
+ void appendLine(HOST_LOG, line);
176
+ };
177
+
178
+ const logClient = (line) => {
179
+ void appendLine(CLIENT_LOG, line);
180
+ };
181
+
182
+ async function readPrefsFile() {
183
+ try {
184
+ const text = await fs.readFile(PREFS_FILE, 'utf8');
185
+ const parsed = JSON.parse(text);
186
+ return isRecord(parsed) ? parsed : null;
187
+ } catch (error) {
188
+ if (error && error.code === 'ENOENT') return null;
189
+ await logHost(`prefs read failed: ${describeError(error)}`);
190
+ return null;
191
+ }
192
+ }
193
+
194
+ async function writePrefsFileNow(prefs) {
195
+ if (!isRecord(prefs)) throw new Error('preferences must be an object');
196
+ const text = JSON.stringify(prefs);
197
+ if (text.length > MAX_PREFS_BYTES) throw new Error('preferences are too large');
198
+ await ensureStateDir();
199
+ const temporary = `${PREFS_FILE}.tmp`;
200
+ await fs.writeFile(temporary, text, 'utf8');
201
+ await fs.rename(temporary, PREFS_FILE);
202
+ return text.length;
203
+ }
204
+
205
+ /**
206
+ * One writer at a time.
207
+ *
208
+ * Two windows (or a retry racing the debounce) otherwise both write the same
209
+ * `.tmp` path: one rename wins, the other fails with EPERM/EEXIST and that save
210
+ * is lost even though the caller was told nothing. A promise chain costs nothing
211
+ * here -- saves are rare and small -- and keeps the last write the one on disk.
212
+ */
213
+ let writeChain = Promise.resolve();
214
+
215
+ function writePrefsFile(prefs) {
216
+ const next = writeChain.then(
217
+ () => writePrefsFileNow(prefs),
218
+ () => writePrefsFileNow(prefs),
219
+ );
220
+ // Keep the chain alive after a rejection: the caller still gets the error.
221
+ writeChain = next.catch(() => {});
222
+ return next;
223
+ }
224
+
225
+ /* ------------------------------------------------------------------- prompt */
226
+
227
+ /**
228
+ * One prompt, no chat history: the model answers a single question about a
229
+ * short list. Everything it needs is inside the message, and the reply is
230
+ * parsed defensively because a model can always answer with prose.
231
+ */
232
+ function buildPrompt(draft) {
233
+ const language = draft.locale === 'zh' ? '中文' : 'English';
234
+ const groupNames = (draft.groups || []).map((group) => group.name).filter(Boolean);
235
+ const entries = draft.entries.map((entry) => ({ id: entry.id, label: entry.label }));
236
+ return [
237
+ `You are filing the entry list of a developer tool's sidebar into a few named sections.`,
238
+ `Answer in ${language}.`,
239
+ ``,
240
+ `Existing section names (reuse them when they fit; you may add new ones):`,
241
+ groupNames.length ? JSON.stringify(groupNames) : '[] (none yet)',
242
+ ``,
243
+ `Entries to file, as JSON:`,
244
+ JSON.stringify(entries),
245
+ ``,
246
+ `Rules:`,
247
+ `- Produce 2 to ${MAX_GROUPS} sections; every entry appears in exactly one section.`,
248
+ `- Section names are short: 2-4 Chinese characters, or 1-3 English words.`,
249
+ `- Group by what the entries are for, not by their order.`,
250
+ `- Answer with a single JSON object and nothing else: {"groups":[{"name":"...","ids":["..."]}]}`,
251
+ `- No prose, no markdown fence, no comments.`,
252
+ ].join('\n');
253
+ }
254
+
255
+ /** Pull the first JSON object out of a model reply (tolerates a code fence). */
256
+ function extractJson(text) {
257
+ const raw = typeof text === 'string' ? text : '';
258
+ const start = raw.indexOf('{');
259
+ const end = raw.lastIndexOf('}');
260
+ if (start < 0 || end <= start) return null;
261
+ try {
262
+ return JSON.parse(raw.slice(start, end + 1));
263
+ } catch {
264
+ return null;
265
+ }
266
+ }
267
+
268
+ /** Keep only the answer this caller can use: known ids, at most MAX_GROUPS
269
+ * sections, short names, no duplicates. */
270
+ function sanitizeAnswer(answer, draft) {
271
+ if (!isRecord(answer) || !Array.isArray(answer.groups)) return null;
272
+ const known = new Set(draft.entries.map((entry) => entry.id));
273
+ const used = new Set();
274
+ const groups = [];
275
+ for (const group of answer.groups) {
276
+ if (!isRecord(group) || !Array.isArray(group.ids)) continue;
277
+ const name = typeof group.name === 'string' ? group.name.trim().slice(0, 24) : '';
278
+ const ids = [];
279
+ for (const id of group.ids) {
280
+ if (typeof id !== 'string' || !known.has(id) || used.has(id)) continue;
281
+ used.add(id);
282
+ ids.push(id);
283
+ }
284
+ if (ids.length === 0) continue;
285
+ groups.push({ name: name || `#${groups.length + 1}`, ids });
286
+ if (groups.length >= MAX_GROUPS) break;
287
+ }
288
+ if (groups.length === 0) return null;
289
+ return { groups };
290
+ }
291
+
292
+ /* --------------------------------------------------------------------- call */
293
+
294
+ function serviceOf(scope, key) {
295
+ try {
296
+ if (scope && typeof scope.get === 'function') {
297
+ const value = scope.get(key);
298
+ if (value !== undefined && value !== null) return value;
299
+ }
300
+ } catch {
301
+ /* a service that is not available yet reads as missing, not as a crash */
302
+ }
303
+ try {
304
+ return scope ? scope[key] : undefined;
305
+ } catch {
306
+ return undefined;
307
+ }
308
+ }
309
+
310
+ function currentModel(scope) {
311
+ const defaults = serviceOf(scope, 'agentDefaultModel');
312
+ if (defaults && typeof defaults.currentSelection === 'function') {
313
+ try {
314
+ const selection = defaults.currentSelection();
315
+ if (selection && typeof selection.provider === 'string' && typeof selection.model === 'string') {
316
+ return selection;
317
+ }
318
+ } catch {
319
+ /* fall through: the LLM service may still know a default */
320
+ }
321
+ }
322
+ return null;
323
+ }
324
+
325
+ /** One non-streaming completion: the joined text of a single stream. */
326
+ async function complete(scope, prompt, signal) {
327
+ const llm = serviceOf(scope, 'llm');
328
+ if (!llm || typeof llm !== 'object') throw new Error('the Host LLM service is not available');
329
+ const selection = currentModel(scope);
330
+ if (!selection) throw new Error('no default model is configured for this profile');
331
+
332
+ const messages = [{ role: 'user', content: [{ type: 'text', text: prompt }] }];
333
+ const request = {
334
+ provider: selection.provider,
335
+ model: selection.model,
336
+ messages,
337
+ maxTokens: MAX_OUTPUT_TOKENS,
338
+ };
339
+ // Filing labels needs no deliberation, and a thinking budget would eat the
340
+ // whole output allowance before a single character of the answer is emitted.
341
+ request.reasoningEffort = 'off';
342
+
343
+ let text = '';
344
+ let finish = null;
345
+ const seen = new Set();
346
+ const consume = async (stream) => {
347
+ for await (const chunk of stream) {
348
+ if (!chunk || typeof chunk !== 'object') continue;
349
+ seen.add(String(chunk.type));
350
+ if (chunk.type === 'text-delta' && typeof chunk.text === 'string') text += chunk.text;
351
+ else if (chunk.type === 'finish') finish = chunk;
352
+ }
353
+ };
354
+
355
+ // `prepareCall` validates the route and freezes the resolved config; the
356
+ // prepared stream only accepts exactly that config, so hand it back as-is.
357
+ let prepared = null;
358
+ if (typeof llm.prepareCall === 'function') prepared = await llm.prepareCall(request, signal);
359
+ if (prepared && typeof prepared.stream === 'function') await consume(prepared.stream(prepared.config));
360
+ else await consume(llm.stream(request));
361
+
362
+ if (finish && isRecord(finish.reason)) {
363
+ const kind = finish.reason.kind;
364
+ if (kind && kind !== 'stop' && kind !== 'max-tokens') throw new Error(`the model stopped early (${kind})`);
365
+ }
366
+ return { text, model: `${selection.provider}/${selection.model}`, seen: Array.from(seen).join('+') };
367
+ }
368
+
369
+ /* ------------------------------------------------------------------ handler */
370
+
371
+ async function classify(scope, payload, signal) {
372
+ if (!isRecord(payload)) return refusal('bad-request', 'The request payload is not an object.');
373
+ const entries = [];
374
+ for (const entry of (Array.isArray(payload.entries) ? payload.entries : []).slice(0, MAX_ENTRIES)) {
375
+ if (!isRecord(entry) || typeof entry.id !== 'string' || entry.id === '') continue;
376
+ entries.push({
377
+ id: entry.id,
378
+ label: typeof entry.label === 'string' && entry.label !== '' ? entry.label.slice(0, 80) : entry.id,
379
+ });
380
+ }
381
+ if (entries.length === 0) return refusal('bad-request', 'There is nothing to file: the entry list is empty.');
382
+
383
+ const groups = [];
384
+ for (const group of (Array.isArray(payload.groups) ? payload.groups : []).slice(0, MAX_GROUPS)) {
385
+ if (!isRecord(group) || typeof group.name !== 'string' || group.name.trim() === '') continue;
386
+ groups.push({ name: group.name.trim().slice(0, 24) });
387
+ }
388
+
389
+ const draft = { entries, groups, locale: payload.locale === 'zh' ? 'zh' : 'en' };
390
+
391
+ const deadline = new AbortController();
392
+ const timer = setTimeout(() => deadline.abort(), TIMEOUT_MS);
393
+ const mergedSignal = () => {
394
+ if (!signal) return deadline.signal;
395
+ if (typeof AbortSignal !== 'undefined' && typeof AbortSignal.any === 'function') {
396
+ return AbortSignal.any([signal, deadline.signal]);
397
+ }
398
+ return deadline.signal;
399
+ };
400
+
401
+ try {
402
+ const answer = await complete(scope, buildPrompt(draft), mergedSignal());
403
+ const parsed = sanitizeAnswer(extractJson(answer.text), draft);
404
+ if (!parsed) {
405
+ logHost(`classify: unusable answer (${answer.seen}, ${answer.text.length} chars)`);
406
+ return refusal('unparsable-answer', 'The model did not answer with usable JSON.');
407
+ }
408
+ const filed = new Set(parsed.groups.flatMap((group) => group.ids));
409
+ logHost(`classify: ${parsed.groups.length} groups over ${filed.size}/${entries.length} entries (${answer.model})`);
410
+ return {
411
+ ok: true,
412
+ value: {
413
+ groups: parsed.groups,
414
+ unfiled: entries.filter((entry) => !filed.has(entry.id)).map((entry) => entry.id),
415
+ model: answer.model,
416
+ },
417
+ };
418
+ } catch (error) {
419
+ const aborted = deadline.signal.aborted || (signal && signal.aborted);
420
+ return refusal(aborted ? 'timeout' : 'model-failed', describeError(error));
421
+ } finally {
422
+ clearTimeout(timer);
423
+ }
424
+ }
425
+
426
+ /* -------------------------------------------------------------------- route */
427
+
428
+ async function handle(scope, request) {
429
+ if (!isOwnRequest(request)) return new Response('forbidden', { status: 403 });
430
+ if (request.method !== 'POST') return new Response('method not allowed', { status: 405 });
431
+ const contentType = request.headers.get('content-type');
432
+ if (!contentType || contentType.split(';', 1)[0].trim().toLowerCase() !== 'application/json') {
433
+ return new Response('content type must be application/json', { status: 415 });
434
+ }
435
+
436
+ // Read the text first: `request.json()` would parse the whole body before the
437
+ // size check could refuse it, which is exactly what the limit exists to stop.
438
+ let raw;
439
+ try {
440
+ raw = await request.text();
441
+ } catch {
442
+ return new Response('body is unreadable', { status: 400 });
443
+ }
444
+ if (raw.length > MAX_BODY_BYTES) return new Response('body too large', { status: 413 });
445
+ let message;
446
+ try {
447
+ message = JSON.parse(raw);
448
+ } catch {
449
+ return new Response('body is not JSON', { status: 400 });
450
+ }
451
+
452
+ const rpcId = isRecord(message) && typeof message.rpcId === 'string' ? message.rpcId : 'invalid-request';
453
+ if (
454
+ !isRecord(message) ||
455
+ message.type !== 'client-request' ||
456
+ typeof message.rpcId !== 'string' ||
457
+ message.method !== ENDPOINT ||
458
+ !isRecord(message.payload)
459
+ ) {
460
+ return reply(rpcId, refusal('gateway/bad-request', 'This is not a panel-control request.'));
461
+ }
462
+ const call = message.payload;
463
+ const method = typeof call.method === 'string' ? call.method : '';
464
+
465
+ try {
466
+ if (method === 'classify') return reply(rpcId, await classify(scope, call.payload, request.signal));
467
+ if (method === 'load') return reply(rpcId, await loadPreferences());
468
+ if (method === 'save') return reply(rpcId, await savePreferences(call.payload));
469
+ if (method === 'inventory') return reply(rpcId, inventorySnapshot(scope));
470
+ if (method === 'report') {
471
+ const detail = isRecord(call.payload) ? call.payload : {};
472
+ const text = JSON.stringify(detail).slice(0, 4_000);
473
+ logClient(`${typeof detail.kind === 'string' ? detail.kind : 'report'} ${text}`);
474
+ return reply(rpcId, { ok: true, value: { logged: true } });
475
+ }
476
+ return reply(rpcId, refusal('unknown-method', `Unknown method: ${String(call.method)}`));
477
+ } catch (error) {
478
+ logHost(`request ${method || '?'} failed: ${describeError(error)}`);
479
+ return reply(rpcId, refusal('handler-failed', describeError(error)));
480
+ }
481
+ }
482
+
483
+ async function loadPreferences() {
484
+ const prefs = await readPrefsFile();
485
+ return { ok: true, value: { prefs, file: PREFS_FILE } };
486
+ }
487
+
488
+ async function savePreferences(payload) {
489
+ if (!isRecord(payload) || !isRecord(payload.prefs)) {
490
+ return refusal('bad-request', 'A preferences object is required.');
491
+ }
492
+ const bytes = await writePrefsFile(payload.prefs);
493
+ return { ok: true, value: { saved: true, bytes } };
494
+ }
495
+
496
+ /**
497
+ * What the Host is serving to pages right now, taken from the same module graph
498
+ * that builds `window.__DSH_BOOT__`. Comparing revisions is the only reliable
499
+ * way to tell "this page booted with an older plugin list": the page cannot
500
+ * read the Host's list, and the profile manifest says nothing about which
501
+ * plugins actually made it into the served graph.
502
+ */
503
+ let clientModules = null;
504
+
505
+ function inventorySnapshot(scope) {
506
+ if (!clientModules) clientModules = serviceOf(scope, 'clientModules');
507
+ const graph = clientModules && typeof clientModules.graph === 'function' ? clientModules.graph() : null;
508
+ if (!graph || typeof graph.rev !== 'string') return { ok: false, error: { message: 'The plugin graph is not readable.' } };
509
+ const entries = Array.isArray(graph.entries) ? graph.entries : [];
510
+ return {
511
+ ok: true,
512
+ value: {
513
+ rev: graph.rev,
514
+ release: typeof graph.release === 'string' ? graph.release : null,
515
+ ids: entries.map((entry) => String(entry && entry.id)).filter(Boolean),
516
+ // True when this process had to hand itself to the page: the page's list is
517
+ // still stale, but the entry it lacks is this very plugin, so there is
518
+ // nothing left for the user to do.
519
+ recovered: recoveredAt > 0,
520
+ },
521
+ };
522
+ }
523
+
524
+ /**
525
+ * Write one line per apply that answers the only question a cold start leaves
526
+ * open: did this package even reach the Host's plugin graph?
527
+ *
528
+ * The page can only load what the graph advertises, so "missing from the list"
529
+ * and "listed but never fetched" have completely different fixes. One line,
530
+ * once per apply, settles it from `host.log` alone.
531
+ */
532
+ function logInventoryOnApply(scope) {
533
+ try {
534
+ const snapshot = inventorySnapshot(scope);
535
+ if (!snapshot.ok) {
536
+ logHost(`graph: unreadable (${String(snapshot.error && snapshot.error.message)})`);
537
+ return;
538
+ }
539
+ const value = snapshot.value;
540
+ const listed = value.ids.includes(PACKAGE);
541
+ logHost(
542
+ `graph: ${listed ? 'lists' : 'does not list'} ${PACKAGE} · rev ${value.rev} · ` +
543
+ `${value.ids.length} client entr${value.ids.length === 1 ? 'y' : 'ies'}`,
544
+ );
545
+ if (listed) void probeOwnBundle(scope);
546
+ } catch (error) {
547
+ logHost(`graph: probe failed (${describeError(error)})`);
548
+ }
549
+ }
550
+
551
+ /**
552
+ * Serve the current revision when a page asks for one this process has retired.
553
+ *
554
+ * The registry addresses every bundle by a revision derived from the file's
555
+ * mtime, ctime and size, and it answers only the revisions it read at
556
+ * composition time ("versioned code is immutable"). A page keeps the list it
557
+ * booted with, so as soon as this package's file is rewritten - by an editor, by
558
+ * git normalising line endings, or by the retry nudge that used to live here -
559
+ * the page's revision stops existing and its request becomes a 404 that nothing
560
+ * reports: the plugin is simply absent from the sidebar.
561
+ *
562
+ * This wraps the registry's own resource lookup for this one package. A 404 for
563
+ * a stale revision is retried at the revision the Host holds now, and the page
564
+ * gets the bytes it needs; everything else passes straight through, and the
565
+ * patch is undone on teardown.
566
+ */
567
+ const SERVE_CURRENT = String(process.env.DPC_SERVE_CURRENT || '').toLowerCase() !== 'off';
568
+ let servePatched = null;
569
+ let serveLoggedAt = 0;
570
+
571
+ function serveCurrentRevision(scope) {
572
+ if (!SERVE_CURRENT || servePatched) return;
573
+ const modules = clientModules || serviceOf(scope, 'clientModules');
574
+ if (!modules || typeof modules.bundleResource !== 'function') return;
575
+ const original = modules.bundleResource.bind(modules);
576
+ servePatched = modules;
577
+ modules.bundleResource = async function bundleResourceWithFallback(method, url) {
578
+ const answer = await original(method, url);
579
+ if (answer && answer.status !== 404) return answer;
580
+ try {
581
+ const requested = String(url || '');
582
+ if (!requested.includes(PACKAGE)) return answer;
583
+ const graph = typeof modules.graph === 'function' ? modules.graph() : null;
584
+ const rows = graph && Array.isArray(graph.entries) ? graph.entries : [];
585
+ const entry = rows.find((row) => row && row.id === PACKAGE);
586
+ if (!entry || typeof entry.url !== 'string') return answer;
587
+ const retryUrl = entry.url.startsWith('/') ? entry.url : `/${entry.url}`;
588
+ if (retryUrl === requested) return answer;
589
+ const retried = await original(method, retryUrl);
590
+ if (!retried || retried.status !== 200) return answer;
591
+ if (Date.now() - serveLoggedAt > 30_000) {
592
+ serveLoggedAt = Date.now();
593
+ logHost(
594
+ `served ${PACKAGE} at the current revision: the page asked for a retired one ` +
595
+ '(its plugin list predates the last write to client.js)',
596
+ );
597
+ }
598
+ return retried;
599
+ } catch (error) {
600
+ logHost(`stale revision fallback failed: ${describeError(error)}`);
601
+ return answer;
602
+ }
603
+ };
604
+ }
605
+
606
+ function releaseServeCurrent() {
607
+ const modules = servePatched;
608
+ servePatched = null;
609
+ if (!modules) return;
610
+ try {
611
+ delete modules.bundleResource;
612
+ } catch {
613
+ /* a frozen service keeps the wrapper; it only ever adds a retry */
614
+ }
615
+ }
616
+
617
+ /**
618
+ * Ask the Host to serve this package's own bundle, the way the page would.
619
+ *
620
+ * A row can be advertised and still be unservable: the registry answers only
621
+ * the revisions it read at composition time, and everything else is a 404 that
622
+ * the page drops without a word. Runs the same request the browser would make.
623
+ */
624
+ async function probeOwnBundle(scope) {
625
+ try {
626
+ const modules = clientModules || serviceOf(scope, 'clientModules');
627
+ const graph = modules && typeof modules.graph === 'function' ? modules.graph() : null;
628
+ const entries = graph && Array.isArray(graph.entries) ? graph.entries : [];
629
+ const entry = entries.find((row) => row && row.id === PACKAGE);
630
+ if (!entry || typeof entry.url !== 'string' || typeof modules.fetchBundle !== 'function') return;
631
+ const response = await modules.fetchBundle({ method: 'GET', url: entry.url });
632
+ logHost(
633
+ `graph: own bundle at rev ${String(entry.rev)} answers ${response.status}` +
634
+ (response.status === 200 ? '' : ' — the page cannot load a bundle the Host will not serve'),
635
+ );
636
+ } catch (error) {
637
+ logHost(`graph: own bundle probe failed (${describeError(error)})`);
638
+ }
639
+ }
640
+
641
+ /**
642
+ * Say it out loud when the page never came back.
643
+ *
644
+ * The Host is up (this half ran) and yet the Client half reports nothing: the
645
+ * window was built from a plugin list that no longer matches the code on disk,
646
+ * so its script request went nowhere and the page silently omits the entry. The
647
+ * user sees a plugin that "does not start", so write the diagnosis where it can
648
+ * be found -- once per launch.
649
+ *
650
+ * This runs in a timer, outside every request and outside Cordis' own error
651
+ * handling: anything thrown here kills the whole Host process. Every branch is
652
+ * therefore contained, and the caller adds one more net.
653
+ */
654
+ let watchdogSpoken = false;
655
+
656
+ async function watchFirstClientBoot(startedAt) {
657
+ try {
658
+ // Several applies can share one process (hot swap); only the first one speaks.
659
+ if (watchdogSpoken) return;
660
+ watchdogSpoken = true;
661
+ let text = '';
662
+ try {
663
+ text = await fs.readFile(CLIENT_LOG, 'utf8');
664
+ } catch (error) {
665
+ if (!error || error.code !== 'ENOENT') throw error;
666
+ }
667
+ const since = startedAt - 4_000;
668
+ let newest = 0;
669
+ let reported = false;
670
+ for (const line of text.split(String.fromCharCode(10))) {
671
+ if (!line.includes('"kind":"boot"')) continue;
672
+ const stamp = Date.parse(line.slice(0, line.indexOf(' ')));
673
+ if (!Number.isFinite(stamp)) continue;
674
+ if (stamp > newest) newest = stamp;
675
+ if (stamp >= since) reported = true;
676
+ }
677
+ if (reported) return;
678
+ // A page that reported shortly before this apply is an already-running page:
679
+ // it did load this plugin, and a Host hot swap (which happens on every edit
680
+ // while developing) must not be reported as "the window never got the code".
681
+ if (newest >= startedAt - 120_000) return;
682
+ logHost(
683
+ 'client half did not report within 30s: the window is running a plugin list from before this code ' +
684
+ '(a rebuilt interface can take this long to finish loading; press the refresh button if the entry is missing)',
685
+ );
686
+ // The page will not ask for a script that is not in its frozen list, but it
687
+ // does watch this package's files: one byte-level change to the Client entry
688
+ // makes the loader hand the module to the live page. Flip the trailing
689
+ // newline, so the file stays byte-stable instead of growing on every launch.
690
+ if (!NUDGE_ENABLED) {
691
+ logHost('recovery nudge stayed off (opt in with DPC_NUDGE=on); a page reload is the safe fix');
692
+ return;
693
+ }
694
+ try {
695
+ // The page may have reported while this timer was waiting: never rewrite a
696
+ // file the running page just loaded, because the resulting hot swap is
697
+ // what re-runs everybody's registration.
698
+ const late = await fs.readFile(CLIENT_LOG, 'utf8').catch(() => '');
699
+ const quiet = late
700
+ .split(String.fromCharCode(10))
701
+ .some((line) => {
702
+ if (!line.includes('"kind":"boot"')) return false;
703
+ const stamp = Date.parse(line.slice(0, line.indexOf(' ')));
704
+ return Number.isFinite(stamp) && stamp >= since;
705
+ });
706
+ if (quiet) {
707
+ logHost('recovery nudge skipped: the page reported while the watchdog was waiting');
708
+ return;
709
+ }
710
+ const source = await fs.readFile(CLIENT_FILE, 'utf8');
711
+ const next = source.endsWith(NL + NL) ? source.slice(0, -1) : source + NL;
712
+ await fs.writeFile(CLIENT_FILE, next, 'utf8');
713
+ recoveredAt = Date.now();
714
+ logHost(
715
+ `recovery nudge: rewrote ${path.basename(CLIENT_FILE)} (${next.length - source.length} byte) so the ` +
716
+ 'loader hands this plugin to the running page; the sidebar entry should appear within a second',
717
+ );
718
+ } catch (error) {
719
+ logHost(`recovery nudge failed: ${describeError(error)}`);
720
+ }
721
+ } catch (error) {
722
+ logHost(`client boot watchdog failed: ${describeError(error)}`);
723
+ }
724
+ }
725
+
726
+ /** Register the route; returns null while `connection` is not available yet so
727
+ * that the caller can retry on a later tick. */
728
+ function mount(scope) {
729
+ const connection = serviceOf(scope, 'connection');
730
+ if (!connection || !connection.fetch || typeof connection.fetch.register !== 'function') return null;
731
+ return connection.fetch.register({
732
+ path: ROUTE_PATH,
733
+ methods: ['POST'],
734
+ requestBody: 'buffered',
735
+ fetch: (request) => handle(scope, request),
736
+ });
737
+ }
738
+
739
+ export function apply(ctx) {
740
+ let disposed = false;
741
+ let dispose = null;
742
+ let retry = null;
743
+ let attempts = 0;
744
+
745
+ logHost('apply: host half starting');
746
+
747
+ const attach = (scope) => {
748
+ if (disposed || dispose) return;
749
+ // A hot swap replaces the services themselves: a reference cached from the
750
+ // previous apply would answer with the old graph.
751
+ clientModules = null;
752
+ attempts += 1;
753
+ try {
754
+ dispose = mount(scope);
755
+ } catch (error) {
756
+ console.error(`[${PACKAGE}] failed to register ${ROUTE_PATH}`, error);
757
+ logHost(`mount failed: ${describeError(error)}`);
758
+ }
759
+ if (dispose) {
760
+ logHost(`route mounted at ${ROUTE_PATH} (attempt ${attempts})`);
761
+ logInventoryOnApply(scope);
762
+ serveCurrentRevision(scope);
763
+ const startedAt = Date.now();
764
+ // A timer callback that throws is fatal for the Host: contain it here and
765
+ // again inside the async body.
766
+ const watchdog = setTimeout(() => {
767
+ void watchFirstClientBoot(startedAt).catch((error) =>
768
+ logHost(`client boot watchdog failed: ${describeError(error)}`),
769
+ );
770
+ }, 30_000);
771
+ if (typeof watchdog.unref === 'function') watchdog.unref();
772
+ return;
773
+ }
774
+ if (!disposed) retry = setTimeout(() => attach(scope), 1000);
775
+ };
776
+
777
+ try {
778
+ if (typeof ctx.inject === 'function') ctx.inject(['connection'], attach);
779
+ else attach(ctx);
780
+ } catch (error) {
781
+ console.error(`[${PACKAGE}] host half could not start`, error);
782
+ }
783
+
784
+ // Cordis calls this on unload: drop the route and any pending retry.
785
+ if (typeof ctx.effect === 'function') {
786
+ ctx.effect(
787
+ () => () => {
788
+ disposed = true;
789
+ if (retry) clearTimeout(retry);
790
+ retry = null;
791
+ if (typeof dispose === 'function') {
792
+ try {
793
+ dispose();
794
+ } catch {
795
+ /* the Host already tore the route down */
796
+ }
797
+ }
798
+ dispose = null;
799
+ releaseServeCurrent();
800
+ },
801
+ `${PACKAGE}: classification route`,
802
+ );
803
+ }
804
+ }