@game25011/dsh-notify-toast 0.0.0-stage → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js ADDED
@@ -0,0 +1,1411 @@
1
+ /**
2
+ * dsh-notify-toast — Host half.
3
+ *
4
+ * Detects "user attention needed" events in the DeepSeek Harness Host process,
5
+ * cleans/merges them into a small ring buffer, and serves them to the browser
6
+ * half over a same-origin long-poll route `/dsh-notify-toast` fenced by the
7
+ * official Connection Host/Origin + signed-cookie check. It also implements the
8
+ * Windows window-raise used when a toast is clicked, and persists its Config
9
+ * through the official settings service.
10
+ *
11
+ * Contract: docs/INTERFACE.md §2. Optional services are injected one at a
12
+ * time so a missing `connection`/`settings` degrades the feature instead of
13
+ * preventing the plugin from mounting.
14
+ *
15
+ * Imports are Node builtins only; `@deepseek-ai/schemastery` is resolved from
16
+ * the active profile at load time (see `resolveConfigSchema`).
17
+ */
18
+
19
+ import { createRequire } from 'node:module'
20
+ import { homedir } from 'node:os'
21
+ import { dirname, join } from 'node:path'
22
+ import { fileURLToPath } from 'node:url'
23
+
24
+ import {
25
+ PKG_ID,
26
+ RPC_CHANNEL,
27
+ SETTINGS_NS,
28
+ VERSION,
29
+ buildConfigSchema,
30
+ createMergeScheduler,
31
+ normalizeConfig,
32
+ unwrapConfigObject,
33
+ validatePatch,
34
+ } from './config.js'
35
+ import { createAllDoneTitleTracker } from './all-done.js'
36
+ import { createGoalSummaryTracker } from './goal-summary.js'
37
+ import { createNativeToastManager } from './native-toast.js'
38
+ import { createTaskbarBadgeManager } from './taskbar-badge.js'
39
+ import { extractFirstTextParagraph, toSingleLine, truncateCodepoints } from './text.js'
40
+ import { focusDesktopWindow, nativeFocusAvailable } from './native-focus.js'
41
+
42
+ export const name = PKG_ID
43
+ export const inject = []
44
+
45
+ /** Event ring buffer size (INTERFACE §2.3). */
46
+ const EVENT_BUFFER_MAX = 200
47
+ /** Maximum events returned by one poll response. */
48
+ const POLL_BATCH_LIMIT = 100
49
+ /** Long-poll wait clamp (INTERFACE §2.3). */
50
+ const POLL_WAIT_MIN = 1000
51
+ const POLL_WAIT_MAX = 25000
52
+ const POLL_WAIT_DEFAULT = 25000
53
+ /** Runtime config cache lifetime; invalidated by settings/document-updated. */
54
+ const CONFIG_CACHE_MS = 500
55
+ /** Duplicate-event window for approval/question/completion (INTERFACE §2.5). */
56
+ const DEDUPE_WINDOW_MS = 5000
57
+ const DEDUPE_LIMIT = 200
58
+ /** How many events to scan backwards for the completion text. */
59
+ const COMPLETION_SCAN = 200
60
+ /** Completion body cap (cleaned code points, ellipsis included). */
61
+ const COMPLETION_TEXT_LIMIT = 400
62
+ /** Question text cap; the Host sends the raw question, the client truncates. */
63
+ const QUESTION_TEXT_LIMIT = 2000
64
+ /** Approval purpose cap (code points). */
65
+ const PURPOSE_LIMIT = 120
66
+ const TOOL_NAME_LIMIT = 120
67
+ /** Session-title scan window (head and tail of the log). */
68
+ const TITLE_SCAN = 200
69
+ const TITLE_CACHE_LIMIT = 300
70
+ /** Fixed body for `kind:'test'` (INTERFACE §2.3); the client may localize instead. */
71
+ const TEST_TEXT = 'Test notification'
72
+ /** Pure route prefix (the client calls `<channel>/<endpoint>` relatively). */
73
+ const RPC_PATH = RPC_CHANNEL
74
+ /** Maximum accepted JSON request body for one RPC call (bytes). */
75
+ const RPC_BODY_LIMIT = 64 * 1024
76
+ /** Endpoint segment grammar expected by the official Connection client. */
77
+ const RPC_ENDPOINT_PATTERN = /^[A-Za-z0-9_$.-]+$/
78
+
79
+ /**
80
+ * Candidate directories that may anchor the profile's `@deepseek-ai/schemastery`
81
+ * resolution. The Desktop Host process is spawned with `cwd` = the active
82
+ * profile directory and does not always receive `DSH_PROFILE_DIR`, so the cwd,
83
+ * its parent layout and the well-known home layouts are all considered.
84
+ *
85
+ * @returns {Array<{dir: string, source: string}>} ordered anchors.
86
+ */
87
+ function profileDirCandidates() {
88
+ const profile = (() => {
89
+ try {
90
+ return process.env.DSH_PROFILE || 'desktop'
91
+ } catch {
92
+ return 'desktop'
93
+ }
94
+ })()
95
+ const candidates = []
96
+ const seen = new Set()
97
+ const push = (dir, source) => {
98
+ if (typeof dir !== 'string' || dir.length === 0 || seen.has(dir)) return
99
+ seen.add(dir)
100
+ candidates.push({ dir, source })
101
+ }
102
+ try {
103
+ push(process.env.DSH_PROFILE_DIR, 'env-profile-dir')
104
+ } catch {
105
+ /* ignore */
106
+ }
107
+ try {
108
+ const cwd = process.cwd()
109
+ push(cwd, 'cwd')
110
+ push(join(cwd, 'profiles', profile), 'cwd-profiles')
111
+ const home = dirname(dirname(cwd))
112
+ push(join(home, 'profiles', profile), 'cwd-home')
113
+ push(join(cwd, '..', '..'), 'cwd-home-raw')
114
+ } catch {
115
+ /* ignore */
116
+ }
117
+ try {
118
+ const home = process.env.DSH_HOME
119
+ if (home) push(join(home, 'profiles', profile), 'env-profile-home')
120
+ push(join(homedir(), '.dsh', 'profiles', profile), 'homedir-profile')
121
+ } catch {
122
+ /* ignore */
123
+ }
124
+ try {
125
+ push(dirname(fileURLToPath(import.meta.url)), 'plugin-dir')
126
+ } catch {
127
+ /* ignore */
128
+ }
129
+ return candidates
130
+ }
131
+
132
+ /**
133
+ * Resolve the profile's `@deepseek-ai/schemastery` and build the all-volatile
134
+ * Config schema. The profile's own copy is preferred because the loader's
135
+ * Config validation uses the `~standard` vendor tag rather than an instanceof
136
+ * check, so cross-instance schemas are accepted. The DSH installation's copy
137
+ * inside `app.asar` is the last resort.
138
+ *
139
+ * @returns {{Config: unknown, source: string}} schema and its provenance.
140
+ */
141
+ function resolveConfigSchema() {
142
+ for (const attempt of profileDirCandidates()) {
143
+ try {
144
+ const requireFrom = createRequire(join(attempt.dir, 'package.json'))
145
+ const schemastery = requireFrom('@deepseek-ai/schemastery')
146
+ const Schema = schemastery?.default ?? schemastery
147
+ return { Config: buildConfigSchema(Schema), source: attempt.source }
148
+ } catch {
149
+ /* try the next anchor */
150
+ }
151
+ }
152
+ try {
153
+ const resources = typeof process.resourcesPath === 'string' && process.resourcesPath
154
+ ? process.resourcesPath
155
+ : join(dirname(process.execPath), 'resources')
156
+ const archiveAnchor = join(resources, 'app.asar', 'dsh', 'node_modules', '@deepseek-ai', 'schemastery', 'package.json')
157
+ const requireFrom = createRequire(archiveAnchor)
158
+ const schemastery = requireFrom('@deepseek-ai/schemastery')
159
+ return { Config: buildConfigSchema(schemastery?.default ?? schemastery), source: 'dsh-archive' }
160
+ } catch {
161
+ return { Config: undefined, source: 'unresolved' }
162
+ }
163
+ }
164
+
165
+
166
+ const resolvedSchema = resolveConfigSchema()
167
+
168
+ /**
169
+ * The all-volatile Config schema, or undefined when no schemastery copy could
170
+ * be resolved (the plugin then runs with in-memory defaults and the settings
171
+ * form is unavailable — docs/INTERFACE.md §2.1).
172
+ *
173
+ * @type {unknown}
174
+ */
175
+ const Config = resolvedSchema.Config
176
+ export { Config }
177
+
178
+
179
+ /**
180
+ * Read an optional Cordis service without a hard inject requirement.
181
+ *
182
+ * @param {any} ctx plugin context.
183
+ * @param {string} serviceName service key.
184
+ * @returns {any} the service or undefined.
185
+ */
186
+ function getService(ctx, serviceName) {
187
+ try {
188
+ return ctx.get(serviceName)
189
+ } catch {
190
+ return undefined
191
+ }
192
+ }
193
+
194
+ /** @returns {string} a short, safe error message. */
195
+ function errorMessage(error) {
196
+ const message = error instanceof Error ? error.message : String(error)
197
+ return message.length > 500 ? `${message.slice(0, 500)}…` : message
198
+ }
199
+
200
+ /**
201
+ * Log a warning through the Cordis logger; never throws.
202
+ *
203
+ * @param {any} ctx plugin context.
204
+ * @param {string} message message.
205
+ * @param {unknown} [error] optional cause.
206
+ */
207
+ function logWarn(ctx, message, error) {
208
+ try {
209
+ const logger = ctx.logger
210
+ if (!logger || typeof logger.warn !== 'function') return
211
+ const detail = error === undefined ? '' : `: ${errorMessage(error)}`
212
+ logger.warn(`[${PKG_ID}] ${message}${detail}`)
213
+ } catch {
214
+ /* logging must never break the plugin */
215
+ }
216
+ }
217
+
218
+ /**
219
+ * Clamp a long-poll wait into the contract range.
220
+ *
221
+ * @param {unknown} value requested wait in ms.
222
+ * @returns {number} wait in [POLL_WAIT_MIN, POLL_WAIT_MAX].
223
+ */
224
+ function clampWait(value) {
225
+ const number = typeof value === 'number' ? value : Number(value)
226
+ if (!Number.isFinite(number)) return POLL_WAIT_DEFAULT
227
+ return Math.min(POLL_WAIT_MAX, Math.max(POLL_WAIT_MIN, Math.round(number)))
228
+ }
229
+
230
+ /**
231
+ * Ring-buffered event queue with long-poll waiters.
232
+ *
233
+ * - ids are monotonically increasing across the Host process lifetime;
234
+ * - only the last `max` events are retained, and a stale cursor is clamped so
235
+ * a Host restart resynchronizes instead of replaying history;
236
+ * - a waiter resolves immediately when an event with a larger id lands, or on
237
+ * its timeout / AbortSignal / queue disposal.
238
+ *
239
+ * @param {object} [options]
240
+ * @param {number} [options.max] ring size.
241
+ * @param {() => number} [options.now] clock.
242
+ * @returns {object} queue API.
243
+ */
244
+ function createEventQueue(options = {}) {
245
+ const max = typeof options.max === 'number' && options.max > 0 ? options.max : EVENT_BUFFER_MAX
246
+ const now = typeof options.now === 'function' ? options.now : () => Date.now()
247
+ const buffer = []
248
+ const waiters = new Set()
249
+ let nextId = 1
250
+ let disposed = false
251
+
252
+ const lastId = () => nextId - 1
253
+
254
+ const collect = (after) => {
255
+ const events = []
256
+ for (const event of buffer) {
257
+ if (event.id <= after) continue
258
+ events.push(event)
259
+ if (events.length >= POLL_BATCH_LIMIT) break
260
+ }
261
+ return events
262
+ }
263
+
264
+ const result = (events, cursor) => ({
265
+ ok: true,
266
+ events,
267
+ next: events.length > 0 ? events[events.length - 1].id : cursor,
268
+ serverTime: now(),
269
+ })
270
+
271
+ const wake = () => {
272
+ for (const waiter of Array.from(waiters)) {
273
+ try {
274
+ const events = collect(waiter.after)
275
+ if (events.length > 0) waiter.finish(events)
276
+ } catch {
277
+ waiter.finish([])
278
+ }
279
+ }
280
+ }
281
+
282
+ return {
283
+ /**
284
+ * Append one event and wake matching waiters.
285
+ * @param {object} payload event body without id/at.
286
+ * @returns {object|null} the stored event, or null after disposal.
287
+ */
288
+ publish(payload) {
289
+ if (disposed) return null
290
+ const event = { id: nextId, at: now(), ...payload }
291
+ nextId += 1
292
+ buffer.push(event)
293
+ if (buffer.length > max) buffer.splice(0, buffer.length - max)
294
+ wake()
295
+ return event
296
+ },
297
+ /**
298
+ * Return events after `after` immediately, or wait for the next one.
299
+ * @param {number} after exclusive cursor.
300
+ * @param {number} wait maximum hold time in ms.
301
+ * @param {AbortSignal} [signal] client-disconnect signal.
302
+ * @returns {Promise<object>} `{ok, events, next, serverTime}`.
303
+ */
304
+ poll(after, wait, signal) {
305
+ if (disposed) return Promise.resolve(result([], lastId()))
306
+ let cursor = typeof after === 'number' && Number.isFinite(after) ? Math.max(0, Math.floor(after)) : 0
307
+ if (cursor > lastId()) cursor = lastId()
308
+ const pending = collect(cursor)
309
+ if (pending.length > 0) return Promise.resolve(result(pending, cursor))
310
+ if (!Number.isFinite(wait) || wait <= 0) return Promise.resolve(result([], cursor))
311
+ if (signal && signal.aborted) return Promise.resolve(result([], cursor))
312
+ return new Promise((resolve) => {
313
+ let timer = null
314
+ let onAbort = null
315
+ let finished = false
316
+ const waiter = {
317
+ after: cursor,
318
+ finish(events) {
319
+ if (finished) return
320
+ finished = true
321
+ waiters.delete(waiter)
322
+ if (timer !== null) {
323
+ try {
324
+ clearTimeout(timer)
325
+ } catch {
326
+ /* ignore */
327
+ }
328
+ timer = null
329
+ }
330
+ if (onAbort && signal) {
331
+ try {
332
+ signal.removeEventListener('abort', onAbort)
333
+ } catch {
334
+ /* ignore */
335
+ }
336
+ onAbort = null
337
+ }
338
+ resolve(result(events, cursor))
339
+ },
340
+ }
341
+ waiters.add(waiter)
342
+ try {
343
+ timer = setTimeout(() => waiter.finish([]), wait)
344
+ } catch {
345
+ waiter.finish([])
346
+ return
347
+ }
348
+ if (signal) {
349
+ onAbort = () => waiter.finish([])
350
+ try {
351
+ signal.addEventListener('abort', onAbort, { once: true })
352
+ } catch {
353
+ onAbort = null
354
+ }
355
+ }
356
+ })
357
+ },
358
+ /** Number of currently held long-poll requests (≈ connected clients). */
359
+ waiterCount() {
360
+ return waiters.size
361
+ },
362
+ /** Current cursor value (id of the last published event). */
363
+ lastId,
364
+ /** Number of retained events. */
365
+ size() {
366
+ return buffer.length
367
+ },
368
+ /** Resolve every waiter with an empty result and drop buffered events. */
369
+ dispose() {
370
+ if (disposed) return
371
+ disposed = true
372
+ for (const waiter of Array.from(waiters)) {
373
+ try {
374
+ waiter.finish([])
375
+ } catch {
376
+ /* ignore */
377
+ }
378
+ }
379
+ waiters.clear()
380
+ buffer.length = 0
381
+ },
382
+ }
383
+ }
384
+
385
+ /**
386
+ * Small time-windowed dedupe set.
387
+ *
388
+ * @param {object} [options]
389
+ * @param {number} [options.windowMs] duplicate window.
390
+ * @param {number} [options.limit] maximum tracked keys.
391
+ * @param {() => number} [options.now] clock.
392
+ * @returns {{seen: Function, clear: Function}} dedupe API.
393
+ */
394
+ function createDedupe(options = {}) {
395
+ const windowMs = typeof options.windowMs === 'number' ? options.windowMs : DEDUPE_WINDOW_MS
396
+ const limit = typeof options.limit === 'number' ? options.limit : DEDUPE_LIMIT
397
+ const now = typeof options.now === 'function' ? options.now : () => Date.now()
398
+ const entries = new Map()
399
+ return {
400
+ /**
401
+ * @param {string} kind event kind.
402
+ * @param {string} key stable identity.
403
+ * @returns {boolean} true when this identity was seen inside the window.
404
+ */
405
+ seen(kind, key) {
406
+ const stamp = now()
407
+ const composite = `${kind}\u0000${key}`
408
+ const previous = entries.get(composite)
409
+ if (previous !== undefined && stamp - previous < windowMs) return true
410
+ entries.set(composite, stamp)
411
+ if (entries.size > limit) {
412
+ for (const [entryKey, at] of entries) {
413
+ if (stamp - at >= windowMs) entries.delete(entryKey)
414
+ }
415
+ while (entries.size > limit) {
416
+ const oldest = entries.keys().next().value
417
+ if (oldest === undefined) break
418
+ entries.delete(oldest)
419
+ }
420
+ }
421
+ return false
422
+ },
423
+ clear() {
424
+ entries.clear()
425
+ },
426
+ }
427
+ }
428
+
429
+ /** @returns {boolean} true for a root (non-subagent, non-fork) session header. */
430
+ function isRootSession(session) {
431
+ const header = session && typeof session === 'object' ? session.header : undefined
432
+ if (!header || typeof header !== 'object') return false
433
+ if (header.parentSession) return false
434
+ if (header.origin === 'subagent') return false
435
+ return true
436
+ }
437
+
438
+ /** @returns {string|undefined} the value when it is a non-empty string. */
439
+ function asString(value) {
440
+ return typeof value === 'string' && value.length > 0 ? value : undefined
441
+ }
442
+
443
+ /** @returns {string|undefined} the latest `session/title` text inside an event array. */
444
+ function lastTitleIn(events) {
445
+ if (!Array.isArray(events)) return undefined
446
+ for (let index = events.length - 1; index >= 0; index -= 1) {
447
+ const event = events[index]
448
+ if (!event || event.type !== 'session/title') continue
449
+ const title = event.data && event.data.title
450
+ if (typeof title === 'string' && title.trim()) return title.trim()
451
+ }
452
+ return undefined
453
+ }
454
+
455
+ /**
456
+ * Build the whole per-instance Host state: config cache, counters, queues,
457
+ * title cache, dedupe and event handlers.
458
+ *
459
+ * @param {any} ctx plugin context.
460
+ * @param {unknown} config Loader-validated Config (volatile references).
461
+ * @returns {object} state API consumed by `apply`.
462
+ */
463
+ function createHostState(ctx, config) {
464
+ const configCache = { at: 0, value: normalizeConfig(unwrapConfigObject(config)) }
465
+ const counters = { questions: 0, approvals: 0, completions: 0, lastEventAt: 0 }
466
+ /** Root-session overlap tracking for the all-done notification (independent of title rename). */
467
+ const rootRunning = new Set()
468
+ let rootFailed = false
469
+ let rootOverlapped = false
470
+ /** Observable transport/config provenance shown by the settings page. */
471
+ const diagnostics = { transport: 'none', configSource: resolvedSchema.source }
472
+ let lastEvent = null
473
+ const titleCache = new Map()
474
+ const dedupe = createDedupe()
475
+ const queue = createEventQueue()
476
+
477
+ const publishEvent = (payload) => {
478
+ const event = queue.publish(payload)
479
+ if (event) {
480
+ counters.lastEventAt = event.at
481
+ lastEvent = { kind: event.kind, at: event.at }
482
+ }
483
+ return event
484
+ }
485
+
486
+ const merger = createMergeScheduler({
487
+ publish: (payload) => publishEvent(payload),
488
+ getIntervalMs: () => readConfig().mergeIntervalMs,
489
+ isDuplicate: (sessionId, turn) => dedupe.seen('complete', `${sessionId ?? '-'}:${turn ?? '-'}`),
490
+ })
491
+
492
+ const goalSummary = createGoalSummaryTracker({
493
+ enabled: () => {
494
+ const value = readConfig()
495
+ return value.enabled !== false && value.completionEnabled !== false && value.goalSummaryEnabled !== false
496
+ },
497
+ getIntervalMs: () => readConfig().mergeIntervalMs,
498
+ onSummary: (payload) => publishEvent(payload),
499
+ })
500
+
501
+ const nativeToast = createNativeToastManager({
502
+ scriptPath: join(dirname(fileURLToPath(import.meta.url)), 'native-toast.ps1'),
503
+ onActivate: (sessionId) => {
504
+ try {
505
+ focusDesktopWindow()
506
+ } catch {
507
+ /* focus is best-effort */
508
+ }
509
+ if (sessionId) publishEvent({ kind: 'activate', sessionId, at: Date.now() })
510
+ },
511
+ logWarn: (message, error) => logWarn(ctx, message, error),
512
+ })
513
+
514
+ const taskbarBadge = createTaskbarBadgeManager({
515
+ scriptPath: join(dirname(fileURLToPath(import.meta.url)), 'taskbar-badge.ps1'),
516
+ logWarn: (message, error) => logWarn(ctx, message, error),
517
+ })
518
+
519
+ /** Multi-session "all done" title marker (opt-in; see lib/all-done.js). */
520
+ const allDoneTitle = createAllDoneTitleTracker({
521
+ getConfig: () => readConfig(),
522
+ renameTitle: (session, title) => {
523
+ const titles = getService(ctx, 'sessionTitle')
524
+ if (!titles || typeof titles.rename !== 'function') return false
525
+ titles.rename(session, title)
526
+ return true
527
+ },
528
+ onError: (error) => logWarn(ctx, 'all-done title marker failed', error),
529
+ })
530
+
531
+ /**
532
+ * Read the live config: Loader references first, the settings descriptor on
533
+ * top (it is the authoritative persisted form). Cached for 500 ms and
534
+ * invalidated by `settings/document-updated`.
535
+ *
536
+ * @returns {typeof DEFAULTS} plain config.
537
+ */
538
+ function readConfig() {
539
+ const stamp = Date.now()
540
+ if (stamp - configCache.at < CONFIG_CACHE_MS) return configCache.value
541
+ let value = normalizeConfig(unwrapConfigObject(config))
542
+ const settings = getService(ctx, 'settings')
543
+ if (settings && typeof settings.describe === 'function') {
544
+ try {
545
+ const descriptor = settings.describe().find((row) => row && row.ns === SETTINGS_NS)
546
+ if (descriptor) value = normalizeConfig({ ...value, ...unwrapConfigObject(descriptor.value) })
547
+ } catch {
548
+ /* keep the Loader-provided values */
549
+ }
550
+ }
551
+ configCache.at = stamp
552
+ configCache.value = value
553
+ return value
554
+ }
555
+
556
+ function invalidateConfig() {
557
+ configCache.at = 0
558
+ }
559
+
560
+ function settingsAvailable() {
561
+ if (!Config) return false
562
+ const settings = getService(ctx, 'settings')
563
+ return Boolean(settings && typeof settings.update === 'function')
564
+ }
565
+
566
+ function rememberTitle(sessionId, title) {
567
+ if (typeof sessionId !== 'string' || sessionId.length === 0) return
568
+ if (typeof title !== 'string' || title.trim() === '') return
569
+ if (titleCache.size >= TITLE_CACHE_LIMIT && !titleCache.has(sessionId)) {
570
+ const oldest = titleCache.keys().next().value
571
+ if (oldest !== undefined) titleCache.delete(oldest)
572
+ }
573
+ titleCache.set(sessionId, title.trim())
574
+ }
575
+
576
+ /**
577
+ * Fold the latest durable title out of a session log (head first, then the
578
+ * tail), without loading the log into the client.
579
+ *
580
+ * @param {any} session Session API object.
581
+ * @param {number} seq exclusive end position.
582
+ * @returns {string|undefined} title text.
583
+ */
584
+ function readTitle(session, seq) {
585
+ try {
586
+ const end = typeof seq === 'number' && Number.isFinite(seq) ? Math.floor(seq) : Number(session?.seq)
587
+ if (!Number.isFinite(end) || end <= 0 || typeof session.snapshotEvents !== 'function') return undefined
588
+ const head = session.snapshotEvents(0, Math.min(end, TITLE_SCAN))
589
+ const headTitle = lastTitleIn(head)
590
+ if (headTitle !== undefined) return headTitle
591
+ const from = Math.max(0, end - TITLE_SCAN)
592
+ return lastTitleIn(session.snapshotEvents(from, end))
593
+ } catch {
594
+ return undefined
595
+ }
596
+ }
597
+
598
+ function titleForSessionId(sessionId) {
599
+ try {
600
+ if (typeof sessionId !== 'string' || sessionId.length === 0) return undefined
601
+ const cached = titleCache.get(sessionId)
602
+ if (typeof cached === 'string') return cached
603
+ const sessions = getService(ctx, 'sessions')
604
+ const session = sessions && typeof sessions.get === 'function' ? sessions.get(sessionId) : undefined
605
+ if (!session) return undefined
606
+ const title = readTitle(session, session.seq)
607
+ rememberTitle(sessionId, title)
608
+ return title
609
+ } catch {
610
+ return undefined
611
+ }
612
+ }
613
+
614
+ function titleOf(session, event) {
615
+ try {
616
+ const sessionId = asString(session && session.id)
617
+ if (!sessionId) return undefined
618
+ const cached = titleCache.get(sessionId)
619
+ if (typeof cached === 'string') return cached
620
+ const title = readTitle(session, event && event.seq)
621
+ rememberTitle(sessionId, title)
622
+ return title
623
+ } catch {
624
+ return undefined
625
+ }
626
+ }
627
+
628
+ /** Pick the approval purpose in the user's locale (fallback: any language). */
629
+ function preferredLocale() {
630
+ try {
631
+ const settings = getService(ctx, 'settings')
632
+ if (settings && typeof settings.describe === 'function') {
633
+ const descriptor = settings.describe().find((row) => row && row.ns === 'locale')
634
+ const preference = descriptor && descriptor.value && descriptor.value.preference
635
+ if (typeof preference === 'string' && /^[A-Za-z]{2,8}/.test(preference)) return preference
636
+ }
637
+ } catch {
638
+ /* fall through */
639
+ }
640
+ return 'zh'
641
+ }
642
+
643
+ /** @returns {string} a short single-line purpose for an approval request. */
644
+ function approvalPurpose(request) {
645
+ try {
646
+ const display = request && request.displayReason
647
+ if (display && typeof display === 'object') {
648
+ for (const key of [preferredLocale(), 'zh', 'en']) {
649
+ const candidate = display[key]
650
+ if (typeof candidate === 'string' && candidate.trim()) return toSingleLine(candidate)
651
+ }
652
+ for (const candidate of Object.values(display)) {
653
+ if (typeof candidate === 'string' && candidate.trim()) return toSingleLine(candidate)
654
+ }
655
+ }
656
+ const reason = request && request.reason
657
+ return typeof reason === 'string' ? toSingleLine(reason) : ''
658
+ } catch {
659
+ return ''
660
+ }
661
+ }
662
+
663
+ /**
664
+ * Extract the completion body: the nearest `assistant/message` at or before
665
+ * `turn/end`, scanning at most COMPLETION_SCAN events backwards.
666
+ *
667
+ * @returns {string|null} cleaned first paragraph capped at 400 code points.
668
+ */
669
+ function completionText(session, event) {
670
+ try {
671
+ const end = Number(event && event.seq)
672
+ if (!Number.isFinite(end) || typeof session.eventAt !== 'function') return null
673
+ const from = Math.max(0, end - COMPLETION_SCAN)
674
+ for (let seq = end - 1; seq >= from; seq -= 1) {
675
+ const sibling = session.eventAt(seq)
676
+ if (!sibling || sibling.type !== 'assistant/message') continue
677
+ // The nearest assistant message decides the body: if it has no text
678
+ // block (tool-only step), the contract says the body is null.
679
+ const paragraph = extractFirstTextParagraph(sibling.data && sibling.data.message && sibling.data.message.content)
680
+ return paragraph ? truncateCodepoints(paragraph, COMPLETION_TEXT_LIMIT) : null
681
+ }
682
+ } catch {
683
+ /* treat a broken log read as "no text" */
684
+ }
685
+ return null
686
+ }
687
+
688
+ function onApprovalRequest(request) {
689
+ try {
690
+ counters.approvals += 1
691
+ const value = readConfig()
692
+ if (!value.enabled || !value.approvalEnabled) return
693
+ const agentId = asString(request && request.agent && request.agent.id)
694
+ const toolName = asString(request && request.toolName)
695
+ const callId = asString(request && request.callId)
696
+ if (dedupe.seen('approval', `${agentId ?? '-'}|${callId ?? toolName ?? '-'}`)) return
697
+ const body = { kind: 'approval' }
698
+ if (agentId) body.sessionId = agentId
699
+ const sessionTitle = titleForSessionId(agentId)
700
+ if (sessionTitle) body.sessionTitle = sessionTitle
701
+ if (toolName) body.toolName = truncateCodepoints(toSingleLine(toolName), TOOL_NAME_LIMIT)
702
+ const purpose = truncateCodepoints(approvalPurpose(request), PURPOSE_LIMIT)
703
+ if (purpose) body.purpose = purpose
704
+ publishEvent(body)
705
+ } catch {
706
+ /* an approval must never be blocked by a notification bug */
707
+ }
708
+ }
709
+
710
+ function onUserQuestion(request) {
711
+ try {
712
+ counters.questions += 1
713
+ const value = readConfig()
714
+ if (!value.enabled || !value.questionEnabled) return
715
+ const agentId = asString(request && request.agent && request.agent.id)
716
+ const questions = Array.isArray(request && request.questions) ? request.questions : []
717
+ const first = questions[0] && typeof questions[0] === 'object' ? questions[0] : undefined
718
+ const waitCallId = request && request.wait ? asString(request.wait.callId) : undefined
719
+ const intentCallId = first && first.intent ? asString(first.intent.callId) : undefined
720
+ const callId = waitCallId ?? intentCallId
721
+ const identity = callId ?? asString(first && first.id) ?? asString(first && first.question)
722
+ if (dedupe.seen('question', `${agentId ?? '-'}|${identity ?? '-'}`)) return
723
+ const body = { kind: 'question' }
724
+ if (agentId) body.sessionId = agentId
725
+ const sessionTitle = titleForSessionId(agentId)
726
+ if (sessionTitle) body.sessionTitle = sessionTitle
727
+ const questionText = asString(first && first.question)
728
+ body.text = questionText ? truncateCodepoints(questionText.trim(), QUESTION_TEXT_LIMIT) : null
729
+ publishEvent(body)
730
+ } catch {
731
+ /* a question must never be blocked by a notification bug */
732
+ }
733
+ }
734
+
735
+ function onSessionEvent(session, event) {
736
+ try {
737
+ if (!event || typeof event.type !== 'string') return
738
+ if (event.type === 'goal/change') {
739
+ const sessionId = asString(session && session.id)
740
+ if (sessionId) goalSummary.onGoalChange(sessionId, event.data || event)
741
+ return
742
+ }
743
+ if (event.type === 'user/message') {
744
+ const source = event.data && event.data.source
745
+ if (source && source.kind === 'goal') {
746
+ const sessionId = asString(session && session.id)
747
+ if (sessionId) goalSummary.onGoalRound(sessionId, source)
748
+ return
749
+ }
750
+ }
751
+ if (event.type === 'session/title') {
752
+ const sessionId = asString(session && session.id)
753
+ const title = event.data && event.data.title
754
+ if (sessionId && typeof title === 'string' && title.trim()) rememberTitle(sessionId, title)
755
+ return
756
+ }
757
+ if (event.type === 'turn/start') {
758
+ if (isRootSession(session)) {
759
+ const goalSessionId = asString(session && session.id)
760
+ if (goalSessionId) goalSummary.onTurnStart(goalSessionId)
761
+ if (rootRunning.size === 0) {
762
+ rootFailed = false
763
+ rootOverlapped = false
764
+ }
765
+ const id = asString(session && session.id)
766
+ if (id) rootRunning.add(id)
767
+ if (rootRunning.size >= 2) rootOverlapped = true
768
+ allDoneTitle.onTurnStart(session)
769
+ }
770
+ return
771
+ }
772
+ if (event.type === 'turn/end') {
773
+ const rootSession = isRootSession(session)
774
+ const reasonKind = event.data && event.data.reason && event.data.reason.kind
775
+ const rootId = rootSession ? asString(session && session.id) : ''
776
+ if (rootSession) {
777
+ if (reasonKind !== 'completed') rootFailed = true
778
+ if (rootId) rootRunning.delete(rootId)
779
+ allDoneTitle.onTurnEnd(session, event)
780
+ }
781
+ const allDone = Boolean(
782
+ rootSession &&
783
+ rootRunning.size === 0 &&
784
+ rootOverlapped &&
785
+ reasonKind === 'completed' &&
786
+ !rootFailed,
787
+ )
788
+ if (rootRunning.size === 0) {
789
+ rootFailed = false
790
+ rootOverlapped = false
791
+ }
792
+ if (reasonKind !== 'completed') {
793
+ if (rootSession && rootId) goalSummary.onTurnAbort(rootId)
794
+ return
795
+ }
796
+ if (!rootSession) return
797
+ counters.completions += 1
798
+ const value = readConfig()
799
+ const sessionId = asString(session.id)
800
+ const turn = typeof event.data.turn === 'number' ? event.data.turn : undefined
801
+ const turnKey =
802
+ turn !== undefined
803
+ ? String(turn)
804
+ : Number.isSafeInteger(event.seq)
805
+ ? 'seq:' + String(event.seq)
806
+ : undefined
807
+ const text = completionText(session, event)
808
+ const sessionTitle = titleOf(session, event)
809
+ // Observe goal state even when completion delivery is disabled. The
810
+ // tracker uses this to keep its terminal cleanup and re-enable baseline
811
+ // consistent; it returns false when delivery is disabled.
812
+ if (goalSummary.onTurnEnd(sessionId, { text, sessionTitle, turnKey })) return
813
+ if (!value.enabled || !value.completionEnabled) return
814
+ merger.queue({ sessionId, sessionTitle, text, turn, allDone })
815
+ return
816
+ }
817
+ } catch {
818
+ /* turn endings must never fail because of the notifier */
819
+ }
820
+ }
821
+
822
+ function status() {
823
+ return {
824
+ settingsAvailable: settingsAvailable(),
825
+ nativeFocus: nativeFocusAvailable(),
826
+ platform: process.platform,
827
+ clients: queue.waiterCount(),
828
+ transport: diagnostics.transport,
829
+ configSource: diagnostics.configSource,
830
+ stats: {
831
+ questions: counters.questions,
832
+ approvals: counters.approvals,
833
+ completions: counters.completions,
834
+ merged: merger.stats().merged,
835
+ lastEventAt: counters.lastEventAt,
836
+ },
837
+ lastEvent: lastEvent === null ? null : { kind: lastEvent.kind, at: lastEvent.at },
838
+ }
839
+ }
840
+
841
+ function failure(code, message) {
842
+ return { ok: false, error: { code, message, details: {} } }
843
+ }
844
+
845
+ function configPayload() {
846
+ return { ok: true, version: VERSION, ns: SETTINGS_NS, config: readConfig(), status: status() }
847
+ }
848
+
849
+ function healthPayload() {
850
+ const current = status()
851
+ return {
852
+ ok: true,
853
+ version: VERSION,
854
+ ns: SETTINGS_NS,
855
+ settingsAvailable: current.settingsAvailable,
856
+ platform: current.platform,
857
+ nativeFocus: current.nativeFocus,
858
+ clients: current.clients,
859
+ status: current,
860
+ }
861
+ }
862
+
863
+ function testPayload() {
864
+ const event = publishEvent({ kind: 'test', text: TEST_TEXT })
865
+ return { ok: true, queued: Boolean(event) }
866
+ }
867
+
868
+ function focusPayload() {
869
+ let native = false
870
+ try {
871
+ native = focusDesktopWindow()
872
+ } catch {
873
+ native = false
874
+ }
875
+ return { ok: true, native: Boolean(native) }
876
+ }
877
+
878
+ function nativeNotifyPayload(payload) {
879
+ if (!payload || typeof payload !== 'object') return null
880
+ const kind = typeof payload.kind === 'string' ? payload.kind : ''
881
+ if (kind !== 'question' && kind !== 'approval' && kind !== 'complete' && kind !== 'test') return null
882
+ const title = typeof payload.title === 'string' ? payload.title.trim().slice(0, 256) : ''
883
+ if (!title) return null
884
+ const body = typeof payload.body === 'string' ? payload.body.slice(0, 2048) : ''
885
+ const sessionId = typeof payload.sessionId === 'string' ? payload.sessionId.slice(0, 256) : ''
886
+ const durationMs = Number(payload.durationMs)
887
+ const progressLabel = typeof payload.progressLabel === 'string' ? payload.progressLabel.slice(0, 32) : ''
888
+ return { kind, title, body, sessionId, durationMs, progressLabel }
889
+ }
890
+
891
+ async function savePayload(payload) {
892
+ const patch = payload && typeof payload === 'object' ? payload.patch : undefined
893
+ const validation = validatePatch(patch)
894
+ if (!validation.ok) return { ok: false, error: validation.error }
895
+ if (Object.keys(validation.patch).length === 0) {
896
+ return { ok: true, config: readConfig(), status: status() }
897
+ }
898
+ const settings = getService(ctx, 'settings')
899
+ if (!Config || !settings || typeof settings.update !== 'function') {
900
+ return {
901
+ ok: false,
902
+ error: { code: 'settings-unavailable', message: 'settings persistence is unavailable; configuration is read-only' },
903
+ }
904
+ }
905
+ try {
906
+ await settings.update(SETTINGS_NS, validation.patch)
907
+ } catch (error) {
908
+ return { ok: false, error: { code: 'settings-write-failed', message: errorMessage(error) } }
909
+ }
910
+ invalidateConfig()
911
+ return { ok: true, config: readConfig(), status: status() }
912
+ }
913
+
914
+ function pollPayload(payload, signal) {
915
+ const after = payload && typeof payload === 'object' ? payload.after : 0
916
+ const wait = payload && typeof payload === 'object' ? payload.wait : POLL_WAIT_DEFAULT
917
+ return queue.poll(after, clampWait(wait), signal)
918
+ }
919
+
920
+ async function handleRpc(endpoint, payload, signal) {
921
+ try {
922
+ switch (endpoint) {
923
+ case 'config':
924
+ return { ok: true, value: configPayload() }
925
+ case 'save':
926
+ return { ok: true, value: await savePayload(payload) }
927
+ case 'poll':
928
+ return { ok: true, value: await pollPayload(payload, signal) }
929
+ case 'health':
930
+ return { ok: true, value: healthPayload() }
931
+ case 'test':
932
+ return { ok: true, value: testPayload() }
933
+ case 'focus':
934
+ return { ok: true, value: focusPayload() }
935
+ case 'native-notify': {
936
+ const input = nativeNotifyPayload(payload)
937
+ if (!input) return failure('invalid-request', 'invalid native notification payload')
938
+ return { ok: true, value: await nativeToast.show(input) }
939
+ }
940
+ case 'taskbar-badge': {
941
+ const count = payload && typeof payload === 'object' ? Number(payload.count) : NaN
942
+ if (!Number.isInteger(count) || count < 0 || count > 99) {
943
+ return failure('invalid-request', 'taskbar badge count must be an integer from 0 to 99')
944
+ }
945
+ if (readConfig().taskbarBadgeEnabled !== true && count > 0) {
946
+ taskbarBadge.setCount(0)
947
+ return { ok: true, value: { ok: true, count: 0, disabled: true } }
948
+ }
949
+ taskbarBadge.setCount(count)
950
+ return { ok: true, value: { ok: true, count } }
951
+ }
952
+ default:
953
+ return failure('unknown-endpoint', `unknown RPC endpoint "${String(endpoint)}"`)
954
+ }
955
+ } catch (error) {
956
+ return failure('internal-error', errorMessage(error))
957
+ }
958
+ }
959
+
960
+ function onGoalActivation(payload) {
961
+ try {
962
+ goalSummary.onActivation(payload)
963
+ } catch {
964
+ /* goal activation must never break the notifier */
965
+ }
966
+ }
967
+
968
+ function warmNativeToast() {
969
+ try {
970
+ const value = readConfig()
971
+ if (value.taskbarBadgeEnabled === true) taskbarBadge.warm()
972
+ if (value.notificationMode !== 'native-progress') return
973
+ void nativeToast.warm()
974
+ } catch {
975
+ /* prewarm is best-effort */
976
+ }
977
+ }
978
+
979
+ function dispose() {
980
+ try {
981
+ goalSummary.dispose()
982
+ } catch {
983
+ /* ignore */
984
+ }
985
+ try {
986
+ merger.dispose()
987
+ } catch {
988
+ /* ignore */
989
+ }
990
+ try {
991
+ nativeToast.dispose()
992
+ } catch {
993
+ /* ignore */
994
+ }
995
+ try {
996
+ taskbarBadge.dispose()
997
+ } catch {
998
+ /* ignore */
999
+ }
1000
+ try {
1001
+ queue.dispose()
1002
+ } catch {
1003
+ /* ignore */
1004
+ }
1005
+ try {
1006
+ allDoneTitle.dispose()
1007
+ } catch {
1008
+ /* ignore */
1009
+ }
1010
+ titleCache.clear()
1011
+ dedupe.clear()
1012
+ }
1013
+
1014
+ return {
1015
+ handleRpc,
1016
+ onApprovalRequest,
1017
+ onUserQuestion,
1018
+ onSessionEvent,
1019
+ onGoalActivation,
1020
+ warmNativeToast,
1021
+ invalidateConfig,
1022
+ readConfig,
1023
+ setTransport(transport) {
1024
+ diagnostics.transport = transport
1025
+ },
1026
+ dispose,
1027
+ // exposed for tests/diagnostics
1028
+ status,
1029
+ }
1030
+ }
1031
+
1032
+ /**
1033
+ * Register the three Host event listeners (approval + question + turn end).
1034
+ * Waterfall listeners always delegate with `next()` even when publishing fails.
1035
+ */
1036
+ function registerEventListeners(ctx, state) {
1037
+ const listen = (eventName, listener, options) => {
1038
+ try {
1039
+ ctx.on(eventName, listener, options)
1040
+ } catch (error) {
1041
+ logWarn(ctx, `failed to listen for ${eventName}`, error)
1042
+ }
1043
+ }
1044
+ // Approval is a waterfall. The UI answerer is registered by the host before
1045
+ // this plugin and may claim the request without calling next(); a normal
1046
+ // listener would then never see it. Prepend this observer, publish, and
1047
+ // always delegate so the UI remains the sole decision maker.
1048
+ listen('approval/request', (request, next) => {
1049
+ try {
1050
+ state.onApprovalRequest(request)
1051
+ } catch {
1052
+ /* never break the approval waterfall */
1053
+ }
1054
+ return typeof next === 'function' ? next() : undefined
1055
+ }, { prepend: true, global: true })
1056
+ listen('user-questions/request', (request, next) => {
1057
+ try {
1058
+ state.onUserQuestion(request)
1059
+ } catch {
1060
+ /* never break the question waterfall */
1061
+ }
1062
+ return typeof next === 'function' ? next() : undefined
1063
+ })
1064
+ listen('session/event', (session, event) => {
1065
+ try {
1066
+ state.onSessionEvent(session, event)
1067
+ } catch {
1068
+ /* never break the session event feed */
1069
+ }
1070
+ })
1071
+ }
1072
+
1073
+ /** Hide the automatic settings form; the client ships its own section. */
1074
+ function installSettingsPolicy(ctx) {
1075
+ try {
1076
+ ctx.inject(['settings'], (child) => {
1077
+ try {
1078
+ child.effect(() => child.settings.configure({ auto: false }, ctx.fiber))
1079
+ } catch (error) {
1080
+ logWarn(ctx, 'failed to hide the automatic settings form', error)
1081
+ }
1082
+ })
1083
+ } catch (error) {
1084
+ logWarn(ctx, 'settings service injection failed', error)
1085
+ }
1086
+ }
1087
+
1088
+ /** Build a transport-level failure result (ConnectionRpcHandlerResult shape). */
1089
+ function rpcFailure(code, message, details) {
1090
+ return { ok: false, error: { code, message, details: details && typeof details === 'object' ? details : {} } }
1091
+ }
1092
+
1093
+ /**
1094
+ * Read one JSON request body with a byte cap.
1095
+ *
1096
+ * @param {import('node:http').IncomingMessage} req request.
1097
+ * @param {number} limit byte cap.
1098
+ * @returns {Promise<{ok: true, value: unknown} | {ok: false, status: number, message: string}>} body result.
1099
+ */
1100
+ function readJsonBody(req, limit) {
1101
+ return new Promise((resolve) => {
1102
+ let size = 0
1103
+ let settled = false
1104
+ const chunks = []
1105
+ const finish = (result) => {
1106
+ if (settled) return
1107
+ settled = true
1108
+ resolve(result)
1109
+ }
1110
+ try {
1111
+ req.on('data', (chunk) => {
1112
+ if (settled) return
1113
+ size += chunk.length
1114
+ if (size > limit) {
1115
+ finish({ ok: false, status: 413, message: 'request body too large' })
1116
+ try {
1117
+ req.destroy()
1118
+ } catch {
1119
+ /* ignore */
1120
+ }
1121
+ return
1122
+ }
1123
+ chunks.push(chunk)
1124
+ })
1125
+ req.on('end', () => {
1126
+ if (settled) return
1127
+ if (size === 0) {
1128
+ finish({ ok: false, status: 400, message: 'empty request body' })
1129
+ return
1130
+ }
1131
+ try {
1132
+ finish({ ok: true, value: JSON.parse(Buffer.concat(chunks).toString('utf8')) })
1133
+ } catch {
1134
+ finish({ ok: false, status: 400, message: 'body is not JSON' })
1135
+ }
1136
+ })
1137
+ req.on('error', () => {
1138
+ finish({ ok: false, status: 400, message: 'request stream failed' })
1139
+ })
1140
+ } catch {
1141
+ finish({ ok: false, status: 400, message: 'request stream failed' })
1142
+ }
1143
+ })
1144
+ }
1145
+
1146
+ /**
1147
+ * Write one Connection-RPC server-response envelope.
1148
+ *
1149
+ * @param {import('node:http').ServerResponse} res response.
1150
+ * @param {string} rpcId correlation id from the client request.
1151
+ * @param {object} result `{ok:true,value}` or `{ok:false,error}`.
1152
+ */
1153
+ function sendRpcResponse(res, rpcId, result) {
1154
+ const body = JSON.stringify({ type: 'server-response', rpcId, result })
1155
+ res.statusCode = 200
1156
+ res.setHeader('content-type', 'application/json; charset=utf-8')
1157
+ res.setHeader('cache-control', 'no-store')
1158
+ res.end(body)
1159
+ }
1160
+
1161
+ /** Extract the endpoint from a `<channel>/<endpoint>` pathname. */
1162
+ function endpointFromPathname(pathname) {
1163
+ const prefix = `${RPC_PATH}/`
1164
+ if (typeof pathname !== 'string' || !pathname.startsWith(prefix)) return undefined
1165
+ const endpoint = pathname.slice(prefix.length)
1166
+ if (!RPC_ENDPOINT_PATTERN.test(endpoint)) return undefined
1167
+ return endpoint
1168
+ }
1169
+
1170
+ /**
1171
+ * The webServer-route transport used when the official
1172
+ * `connection.rpc.handle` is unavailable (DSH 0.2.1-rc.2 throws
1173
+ * `cannot get property "webServer" without inject` inside its own service).
1174
+ * The route reuses the official Connection fence (`requestRejection`: Host /
1175
+ * Origin checks plus authenticated browser cookie) and the official unary RPC
1176
+ * envelope, so the browser half calls it through `connection.rpc.call`
1177
+ * unchanged.
1178
+ *
1179
+ * @param {import('node:http').IncomingMessage} req request.
1180
+ * @param {import('node:http').ServerResponse} res response.
1181
+ * @param {object} state host state (`handleRpc`).
1182
+ * @param {object} connection Connection service (fence provider).
1183
+ */
1184
+ async function handleRpcRoute(req, res, state, connection) {
1185
+ try {
1186
+ let rejection
1187
+ try {
1188
+ // Fail closed: without requestRejection we cannot prove the Host/Origin/cookie fence.
1189
+ rejection = typeof connection.requestRejection === 'function' ? connection.requestRejection(req) : 403
1190
+ } catch {
1191
+ rejection = 403
1192
+ }
1193
+ if (rejection !== undefined && rejection !== null) {
1194
+ res.statusCode = rejection
1195
+ res.setHeader('cache-control', 'no-store')
1196
+ res.end(rejection === 401 ? 'unauthorized' : 'forbidden')
1197
+ return
1198
+ }
1199
+ let pathname = '/'
1200
+ try {
1201
+ pathname = new URL(req.url ?? '/', 'http://127.0.0.1').pathname
1202
+ } catch {
1203
+ res.statusCode = 400
1204
+ res.end('invalid request target')
1205
+ return
1206
+ }
1207
+ const endpoint = endpointFromPathname(pathname)
1208
+ if (endpoint === undefined) {
1209
+ res.statusCode = 404
1210
+ res.end('not found')
1211
+ return
1212
+ }
1213
+ if (req.method !== 'POST') {
1214
+ res.statusCode = 405
1215
+ res.setHeader('allow', 'POST')
1216
+ res.end()
1217
+ return
1218
+ }
1219
+ const contentType = String(req.headers['content-type'] ?? '').split(';', 1)[0].trim().toLowerCase()
1220
+ if (contentType !== 'application/json') {
1221
+ res.statusCode = 415
1222
+ res.end('content type must be application/json')
1223
+ return
1224
+ }
1225
+ const body = await readJsonBody(req, RPC_BODY_LIMIT)
1226
+ if (!body.ok) {
1227
+ res.statusCode = body.status
1228
+ res.end(body.message)
1229
+ return
1230
+ }
1231
+ const message = body.value
1232
+ if (!message || typeof message !== 'object' || message.type !== 'client-request'
1233
+ || typeof message.rpcId !== 'string' || typeof message.method !== 'string') {
1234
+ res.statusCode = 400
1235
+ res.end('invalid client-request message')
1236
+ return
1237
+ }
1238
+ if (message.method !== endpoint) {
1239
+ sendRpcResponse(res, message.rpcId, rpcFailure('gateway/bad-request', `method ${JSON.stringify(message.method)} does not match endpoint ${JSON.stringify(endpoint)}`))
1240
+ return
1241
+ }
1242
+ const controller = new AbortController()
1243
+ try {
1244
+ req.once('aborted', () => controller.abort())
1245
+ res.once('close', () => {
1246
+ if (!res.writableEnded) controller.abort()
1247
+ })
1248
+ } catch {
1249
+ /* ignore */
1250
+ }
1251
+ let result
1252
+ try {
1253
+ result = await state.handleRpc(endpoint, message.payload === undefined ? null : message.payload, controller.signal)
1254
+ } catch (error) {
1255
+ result = rpcFailure('internal-error', errorMessage(error))
1256
+ }
1257
+ if (!res.writableEnded) sendRpcResponse(res, message.rpcId, result)
1258
+ } catch (error) {
1259
+ try {
1260
+ if (!res.writableEnded) {
1261
+ res.statusCode = 500
1262
+ res.end('handler failure')
1263
+ }
1264
+ } catch {
1265
+ /* response is gone */
1266
+ }
1267
+ }
1268
+ }
1269
+
1270
+ /**
1271
+ * Register the browser transport. Preferred path: official
1272
+ * `connection.rpc.handle` (it owns the route fence and envelope). Fallback
1273
+ * (needed on 0.2.1-rc.2): register the exact same prefix path through
1274
+ * `webServer` and reuse `connection.requestRejection` + the official unary
1275
+ * envelope. Both registrations belong to the injected child fiber, so plugin
1276
+ * unload removes the route.
1277
+ *
1278
+ * @param {any} ctx plugin context.
1279
+ * @param {object} state host state.
1280
+ */
1281
+ function installRpcChannel(ctx, state) {
1282
+ try {
1283
+ ctx.inject(['connection'], (connCtx) => {
1284
+ let connection
1285
+ try {
1286
+ connection = connCtx.connection
1287
+ } catch (error) {
1288
+ state.setTransport('failed')
1289
+ logWarn(ctx, 'connection service is unavailable; the browser half cannot poll', error)
1290
+ return
1291
+ }
1292
+ try {
1293
+ const rpc = connection && connection.rpc
1294
+ if (rpc && typeof rpc.handle === 'function') {
1295
+ const registration = rpc.handle(RPC_CHANNEL, (endpoint, payload, signal) => state.handleRpc(endpoint, payload, signal))
1296
+ try {
1297
+ if (typeof registration === 'function') {
1298
+ connCtx.effect(() => () => {
1299
+ try {
1300
+ Promise.resolve(registration()).catch(() => {})
1301
+ } catch {
1302
+ /* ignore */
1303
+ }
1304
+ }, 'dsh-notify-toast: official RPC channel')
1305
+ }
1306
+ } catch {
1307
+ /* cleanup is optional; the route dies with the connection service */
1308
+ }
1309
+ state.setTransport('channel')
1310
+ return
1311
+ }
1312
+ } catch (error) {
1313
+ // DSH 0.2.1-rc.2: the service's own fiber lacks webServer, so
1314
+ // rpc.handle throws before registering anything. Fall through.
1315
+ logWarn(ctx, 'official connection.rpc.handle is unavailable; using the same-path route fallback', error)
1316
+ }
1317
+ try {
1318
+ connCtx.inject(['webServer'], (webCtx) => {
1319
+ try {
1320
+ const dispose = webCtx.webServer.register({
1321
+ kind: 'prefix',
1322
+ path: RPC_PATH,
1323
+ handler: (req, res) => handleRpcRoute(req, res, state, connection),
1324
+ })
1325
+ try {
1326
+ webCtx.effect(() => dispose, 'dsh-notify-toast: RPC route')
1327
+ } catch {
1328
+ /* the disposer also runs when the webServer itself unloads */
1329
+ }
1330
+ state.setTransport('route')
1331
+ } catch (error) {
1332
+ state.setTransport('failed')
1333
+ logWarn(ctx, 'failed to register the browser RPC route', error)
1334
+ }
1335
+ })
1336
+ } catch (error) {
1337
+ state.setTransport('failed')
1338
+ logWarn(ctx, 'webServer service injection failed', error)
1339
+ }
1340
+ })
1341
+ } catch (error) {
1342
+ state.setTransport('failed')
1343
+ logWarn(ctx, 'connection service injection failed', error)
1344
+ }
1345
+ }
1346
+
1347
+ /**
1348
+ * Cordis plugin entry point.
1349
+ *
1350
+ * No global side effects: every listener is registered on this context (auto
1351
+ * cleanup on unload), every timer is owned by the queue/merger that the single
1352
+ * cleanup effect disposes, and the browser route belongs to the injected child.
1353
+ *
1354
+ * @param {any} ctx plugin context.
1355
+ * @param {unknown} config Loader-validated Config.
1356
+ */
1357
+ export function apply(ctx, config) {
1358
+ let state
1359
+ try {
1360
+ state = createHostState(ctx, config)
1361
+ } catch (error) {
1362
+ logWarn(ctx, 'failed to initialize the notifier host state', error)
1363
+ return
1364
+ }
1365
+ try {
1366
+ ctx.effect(() => () => state.dispose(), 'dsh-notify-toast: event queue, merge timer and waiters')
1367
+ } catch (error) {
1368
+ logWarn(ctx, 'failed to register the cleanup effect', error)
1369
+ }
1370
+ try {
1371
+ ctx.on('settings/document-updated', (ns) => {
1372
+ try {
1373
+ if (ns === SETTINGS_NS) state.invalidateConfig()
1374
+ } catch {
1375
+ /* ignore */
1376
+ }
1377
+ })
1378
+ } catch (error) {
1379
+ logWarn(ctx, 'failed to listen for settings updates', error)
1380
+ }
1381
+ registerEventListeners(ctx, state)
1382
+ try {
1383
+ ctx.on('goal/activation-changed', (payload) => state.onGoalActivation(payload))
1384
+ } catch (error) {
1385
+ logWarn(ctx, 'failed to listen for goal activation changes', error)
1386
+ }
1387
+ state.warmNativeToast()
1388
+ installSettingsPolicy(ctx)
1389
+ installRpcChannel(ctx, state)
1390
+ try {
1391
+ if (ctx.logger && typeof ctx.logger.debug === 'function') {
1392
+ ctx.logger.debug(`[${PKG_ID}] host ready (v${VERSION}); transport=${state.status().transport}; settingsAvailable=${state.status().settingsAvailable}`)
1393
+ }
1394
+ } catch {
1395
+ /* ignore */
1396
+ }
1397
+ }
1398
+
1399
+ /**
1400
+ * Test-only seam for the transport helpers. Not part of the plugin API; the
1401
+ * runtime never reads it.
1402
+ */
1403
+ export const __test = Object.freeze({
1404
+ endpointFromPathname,
1405
+ readJsonBody,
1406
+ sendRpcResponse,
1407
+ handleRpcRoute,
1408
+ profileDirCandidates,
1409
+ resolveConfigSchema,
1410
+ resolvedConfigSource: resolvedSchema.source,
1411
+ })