@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/AI-DISCLOSURE.md +29 -0
- package/CHANGELOG.md +87 -0
- package/LICENSE +22 -0
- package/README.en.md +290 -0
- package/README.md +308 -3
- package/THIRD_PARTY_NOTICES.md +22 -0
- package/assets/sounds/bubble.ogg +0 -0
- package/cordis.patch.yml +5 -0
- package/docs/INTERFACE.md +180 -0
- package/docs/RELEASING.md +282 -0
- package/docs/TEST-REPORT.md +183 -0
- package/docs/assets/native-toast.png +0 -0
- package/dsh-plugin.json +14 -0
- package/icon.svg +17 -0
- package/lib/all-done.js +126 -0
- package/lib/client.js +3486 -0
- package/lib/config.js +420 -0
- package/lib/goal-summary.js +438 -0
- package/lib/index.js +1411 -0
- package/lib/native-focus.js +350 -0
- package/lib/native-toast-helper.cs +206 -0
- package/lib/native-toast.js +372 -0
- package/lib/native-toast.ps1 +72 -0
- package/lib/taskbar-badge-helper.cs +126 -0
- package/lib/taskbar-badge-main.cs +43 -0
- package/lib/taskbar-badge.js +347 -0
- package/lib/taskbar-badge.ps1 +59 -0
- package/lib/text.js +174 -0
- package/locale/en.json +17 -0
- package/locale/zh.json +17 -0
- package/package.json +76 -6
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
|
+
})
|