dsh-push-host 0.0.0-stage → 0.3.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,1422 @@
1
+ /**
2
+ * dsh-push-host — in-process DSH host plugin: question notifications to the phone, plus
3
+ * phone answers for questions the agent has already given up waiting on (`continued`).
4
+ *
5
+ * Design (2026-10-12, "A path"): the plugin lives INSIDE the DSH host and uses DSH's own
6
+ * question seam instead of an out-of-process HTTP/WS bridge.
7
+ *
8
+ * 1. notification layer — it claims the host waterfall `user-questions/request`
9
+ * (`inject: ['userQuestions']`, prepended listener), pushes the question to ntfy and
10
+ * registers it at the answer relay, then returns `next()`: the foreground answering
11
+ * right stays with the Web UI, which keeps working exactly as before.
12
+ * 2. late-answer layer — a poll loop drains the relay's per-host queue
13
+ * (`lib/relay.js`); each answer is applied with
14
+ * `ctx.userQuestions.answer(liveRootAgent, callId, { answers })`, which steers a
15
+ * `user-question-reply` user message into the agent. This route only exists for
16
+ * questions whose foreground wait already ended, i.e. questions asked with the timed
17
+ * tool (see "timed" below).
18
+ *
19
+ * Nothing here talks to DSH's Web API: no cookie, no `/api/remote.mux`, no RPC.
20
+ *
21
+ * `request.wait?.timed` (the probe that decides whether the late-answer layer can ever
22
+ * fire): `askTimed()` sets `wait = { callId, timed: true }` and only that shape folds into
23
+ * the session projection, which is where `answer()` looks the question up. `ask_user_question`
24
+ * reaches it only when `tool-ask-user` runs with `mode: timed`; in the shipped default
25
+ * (`mode: legacy`) `wait` carries just `callId`, no question ever folds, `answer()` always
26
+ * returns false and a phone answer can only be applied through the Web UI. We therefore log
27
+ * `timed=` for every question and refuse (loudly, once) to apply a phone answer to a
28
+ * legacy-mode question instead of silently acking it.
29
+ *
30
+ * Credentials (v0.1.0): the three secrets come from a **credentials file** first and environment
31
+ * variables second, so a real user is onboarded by saving the pairing payload
32
+ * `deploy/relay-user-add.sh <user>` printed (or `npx dsh-push-pair` output) as
33
+ * `~/.config/dsh-push/credentials.json` and never touching env vars. Layering, later wins:
34
+ *
35
+ * defaults → credentials file → profile/overlay config → environment variables
36
+ *
37
+ * A missing credentials file is a clean no-op (it does not even log), which keeps the plugin a
38
+ * transparent listener; an unreadable/broken one is logged once and otherwise ignored. Env-var-only
39
+ * setups (`DSH_PUSH_HOST_TOKEN` / `DSH_PUSH_REG_TOKEN` / `DSH_PUSH_NTFY_TOKEN`) keep working
40
+ * unchanged, because the environment still outranks the file.
41
+ *
42
+ * @module dsh-push-host
43
+ */
44
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
45
+ import { homedir } from 'node:os'
46
+ import { join } from 'node:path'
47
+ import { randomUUID } from 'node:crypto'
48
+ import z from '@deepseek-ai/schemastery'
49
+ import { RelayClient } from './relay.js'
50
+ import { credentialsPath, loadCredentials } from './credentials.mjs'
51
+
52
+ /** Cordis plugin name used by loader diagnostics. */
53
+ export const name = 'dsh-push-host'
54
+
55
+ /**
56
+ * `userQuestions` owns the `user-questions/request` waterfall; `timer` mixes
57
+ * `ctx.interval` / `ctx.timeout` onto the context (auto-disposed with this plugin).
58
+ * `configEditor` / `profileContext` / `tools` back the 手机作答模式 switch (v0.2.0);
59
+ * a host without them simply keeps the switch inert (see `AskUserModeSwitch.start`).
60
+ */
61
+ export const inject = ['userQuestions', 'timer', 'configEditor', 'profileContext', 'tools']
62
+
63
+ /** Shipped relay base (a self-hosted ntfy + answer relay); every field below has a default. */
64
+ export const RELAY_DEFAULT = 'https://ntfy.cpyh.xyz'
65
+
66
+ /** Every field has a default, so mounting without config can never break a boot. */
67
+ const DEFAULTS = {
68
+ relayBase: RELAY_DEFAULT,
69
+ topic: 'dsh',
70
+ host: '',
71
+ hostToken: '',
72
+ hostTokenEnv: 'DSH_PUSH_HOST_TOKEN',
73
+ regToken: '',
74
+ regTokenEnv: 'DSH_PUSH_REG_TOKEN',
75
+ ntfyToken: '',
76
+ phoneToken: '',
77
+ ntfyTokenEnv: 'DSH_PUSH_NTFY_TOKEN',
78
+ // 自助发号(v0.3.0):中继上的 dsh-provd 服务地址与那把共享密钥(见 relay/README.md)。
79
+ provisionBase: 'https://ntfy.cpyh.xyz/provision',
80
+ provisionToken: '',
81
+ provisionTokenEnv: 'DSH_PUSH_PROVISION_TOKEN',
82
+ credentialsFile: '',
83
+ webLog: join(homedir(), '.dsh', 'web.log'),
84
+ webBase: '',
85
+ webName: '',
86
+ answerBase: '',
87
+ pollIntervalMs: 3000,
88
+ pushQuestions: true,
89
+ notifyTurns: true,
90
+ echoAnswers: true,
91
+ claimForeground: false,
92
+ log: true,
93
+ }
94
+
95
+ /** Config keys (schema/overlay vocabulary) = the defaults above. */
96
+ const CONFIG_KEYS = Object.keys(DEFAULTS)
97
+
98
+ /** Declared schema (loader diagnostics / `--dump-config-schema`). */
99
+ export const Config = z.object({
100
+ /** Relay base URL (also serves the phone's `/answer/<callId>` page). */
101
+ relayBase: z.string().default('https://ntfy.cpyh.xyz'),
102
+ /** ntfy topic this host publishes to and the phone subscribes to. */
103
+ topic: z.string().default('dsh'),
104
+ /** Host id provisioned by `deploy/relay-user-add.sh` (empty disables the relay side). */
105
+ host: z.string().default(''),
106
+ /** `hk_…` host token for the queue endpoints; prefer `hostTokenEnv`. */
107
+ hostToken: z.string().default(''),
108
+ hostTokenEnv: z.string().default('DSH_PUSH_HOST_TOKEN'),
109
+ /** Relay registration token (`/etc/answer-relay/regtoken`); prefer `regTokenEnv`. */
110
+ regToken: z.string().default(''),
111
+ regTokenEnv: z.string().default('DSH_PUSH_REG_TOKEN'),
112
+ /** ntfy write token of the host user (`tk_…`); prefer `ntfyTokenEnv`. */
113
+ ntfyToken: z.string().default(''),
114
+ ntfyTokenEnv: z.string().default('DSH_PUSH_NTFY_TOKEN'),
115
+ /** 发号服务基址(中继 `dsh-provd`,经 edge nginx 暴露在 /provision/*)。 */
116
+ provisionBase: z.string().default('https://ntfy.cpyh.xyz/provision'),
117
+ /** 发号服务的共享密钥(`/etc/dsh-provd/secret`);优先 `provisionTokenEnv`。 */
118
+ provisionToken: z.string().default(''),
119
+ provisionTokenEnv: z.string().default('DSH_PUSH_PROVISION_TOKEN'),
120
+ /** ntfy read token of the phone user (`tk_…`): not used here, carried for the pairing payload. */
121
+ phoneToken: z.string().default(''),
122
+ /** Credentials file; empty = `$DSH_PUSH_CREDENTIALS` else `~/.config/dsh-push/credentials.json`. */
123
+ credentialsFile: z.string().default(''),
124
+ /** Where DSH writes its Web URL + token; only used for the `#web` trailer. */
125
+ webLog: z.string().default(join(homedir(), '.dsh', 'web.log')),
126
+ /** Tailnet-facing Web base used to rebuild the `#web` link (empty = use the web.log URL as-is). */
127
+ webBase: z.string().default(''),
128
+ /** Friendly instance name appended as `#name` (multi-DSH chooser on the phone). */
129
+ webName: z.string().default(''),
130
+ /** Base for the phone's answer page; defaults to `relayBase`. */
131
+ answerBase: z.string().default(''),
132
+ /** Relay queue poll cadence. */
133
+ pollIntervalMs: z.number().step(1).min(500).max(60000).default(3000),
134
+ /** Push a notification for every pending question. */
135
+ pushQuestions: z.boolean().default(true),
136
+ /** Send the silent `#ans` echo once an answer is delivered (clears the phone card). */
137
+ echoAnswers: z.boolean().default(true),
138
+ /**
139
+ * Opt-in: hold the foreground waterfall open and race the phone against the Web UI.
140
+ * Off by default — the Web UI owns foreground answering (see the module doc).
141
+ */
142
+ claimForeground: z.boolean().default(false),
143
+ /** Print diagnostics through ctx.logger + console. */
144
+ log: z.boolean().default(true),
145
+ /**
146
+ * 手机作答模式(v0.2.1):期望的 ask_user_question 变体。**只有 'timed' 会触发写入**——
147
+ * 本开关永不降级(0.2.0 的 P0:默认 'legacy' + 开机 sync 把生产 tool-ask-user 反向改回
148
+ * legacy)。未表态(字段缺失=schemastery 不加 default/required 即 optional,值 undefined)
149
+ * = 完全惰性,宿主一个字都不写。注意 schemastery 没有 `.optional()` 方法(也没有
150
+ * `z.literal`,字面量用简写数组或 `z.const`),别写 `.optional()`——会把整个 Config 炸掉。
151
+ * volatile 是为了 Web 设置行能经 settings RPC 写它(mode/timeout 在 tool-ask-user 那边
152
+ * 不是 volatile,客户端写不了);真正的改写由宿主延后到 HMR 事务之外执行(事务内嵌套
153
+ * configEditor.edit 会抛 "HMR transactions cannot be nested")。
154
+ */
155
+ askUserMode: z.union(['legacy', 'timed']).volatile(),
156
+ /** 上次切换的结果('' = 成功/无待办);volatile,好让 Web 设置行显示失败原因。 */
157
+ askUserStatus: z.string().default('').volatile(),
158
+ /**
159
+ * 实际态镜像('' = 宿主还没回写):设置行展示读这个,**不拿期望态当展示值**——P0 之后
160
+ * 期望态和实际态可以相反(期望未表态、实际 timed)。
161
+ */
162
+ askUserActual: z.union(['', 'legacy', 'timed']).default('').volatile(),
163
+ /**
164
+ * 通行证发号面板(v0.3.0)的往返字段,全部 volatile:网页设置行经 settings RPC 只能写
165
+ * volatile 字段(见本文件 823 行那段实测结论),所以「请求」由浏览器写这几个字段来表达,
166
+ * 宿主跑完发号再把结果镜像回 provisionResult / provisionList / provisionDone。
167
+ * provisionSeq 是浏览器生成的请求号:宿主做完原样回写,页面据此清 busy——同一个名字
168
+ * 连点两次也必须各得一次结果,所以不能只靠 action 字段的变化触发。
169
+ */
170
+ provisionName: z.string().default('').volatile(),
171
+ provisionAction: z.union(['', 'issue', 'revoke', 'refresh']).default('').volatile(),
172
+ provisionSeq: z.string().default('').volatile(),
173
+ /** 最近一次操作的结果文本('' = 无):成功时是通行证原文,失败时是给人看的中文原因。 */
174
+ provisionResult: z.string().default('').volatile(),
175
+ /** 已发通行证列表的 JSON 文本(宿主写,页面渲染成可吊销的列表)。 */
176
+ provisionList: z.string().default('').volatile(),
177
+ /** 宿主建议的默认名字(本机名清洗过),页面预填、用户可改。 */
178
+ provisionDefault: z.string().default('').volatile(),
179
+ /** 宿主处理完的请求号回写。 */
180
+ provisionDone: z.string().default('').volatile(),
181
+ })
182
+
183
+ /** `[{label, description?}]` (service vocabulary) → `[label]` (what the relay/phone render). */
184
+ function optionLabels(question) {
185
+ return (question.options ?? [])
186
+ .map((option) => (typeof option === 'string' ? option : option?.label))
187
+ .filter((label) => typeof label === 'string' && label.length > 0)
188
+ }
189
+
190
+ /** Project the service's camelCase questions into the relay's `{id, question, options:[str]}`. */
191
+ function relayQuestions(questions) {
192
+ return questions.map((question, index) => ({
193
+ id: typeof question?.id === 'string' && question.id.length > 0 ? question.id : 'q' + String(index),
194
+ question: typeof question?.question === 'string' ? question.question : '',
195
+ options: optionLabels(question ?? {}),
196
+ }))
197
+ }
198
+
199
+ /** One human-readable line per answer, the same shape the hook and the relay echo use. */
200
+ function answerSummary(answers) {
201
+ const parts = []
202
+ for (const answer of answers ?? []) {
203
+ if (answer === null || typeof answer !== 'object') continue
204
+ const selected = Array.isArray(answer.selected) ? answer.selected : []
205
+ const text = selected.length > 0 ? String(selected[0]) : String(answer.custom ?? '')
206
+ if (text.length > 0) parts.push(text)
207
+ }
208
+ return parts.join(' / ')
209
+ }
210
+
211
+ /** The tool result of a question call: `{pending:true}` means the wait timed out unanswered. */
212
+ function resultValue(result) {
213
+ return result !== null && typeof result === 'object' && 'value' in result ? result.value : result
214
+ }
215
+
216
+ /** First error that carries a service code (NO_PROVIDER / ASK_TIMED_OUT …) wins the report. */
217
+ function preferCodedError(errors) {
218
+ return errors.find((error) => typeof error?.code === 'string') ?? errors[0]
219
+ }
220
+
221
+ class PushHost {
222
+ constructor(ctx, config) {
223
+ this.ctx = ctx
224
+ this.config = config
225
+ this.relay = new RelayClient({
226
+ relayBase: config.relayBase,
227
+ host: config.host,
228
+ hostToken: config.hostToken,
229
+ regToken: config.regToken,
230
+ ntfyToken: config.ntfyToken,
231
+ topic: config.topic,
232
+ })
233
+ /** callIds whose foreground waterfall we hold (only when claimForeground). */
234
+ this.waiters = new Map()
235
+ /** callId → { questions (relay shape), timed, ts, echoed }. */
236
+ this.questions = new Map()
237
+ /** `sessionId:turn` → timestamp, so one turn pushes once (the Stop-hook dedupe). */
238
+ this.turns = new Map()
239
+ /** callId → `{ ts, timed }`: one push per question, plus the value we actually logged. */
240
+ this.announced = new Map()
241
+ /** callId → timestamp of the last complaint we logged (log throttling). */
242
+ this.complained = new Map()
243
+ this.polling = false
244
+ /** Last poll's callId set, so the queue line is printed only when it changes. */
245
+ this.lastQueue = ''
246
+ }
247
+
248
+ get enabled() {
249
+ return this.config.host.length > 0 && this.config.hostToken.length > 0
250
+ }
251
+
252
+ log(message) {
253
+ if (this.config.log !== true) return
254
+ try {
255
+ this.ctx.logger('dsh-push-host').info(message)
256
+ } catch {
257
+ /* ctx.logger is not wired in a bare profile */
258
+ }
259
+ console.log('[dsh-push-host] ' + message)
260
+ }
261
+
262
+ start() {
263
+ if (!this.enabled) {
264
+ this.log('relay disabled (set host + hostToken, or DSH_PUSH_HOST_TOKEN): push/answer off')
265
+ return
266
+ }
267
+ this.ctx.interval(() => {
268
+ void this.pollOnce()
269
+ }, this.config.pollIntervalMs)
270
+ this.log(
271
+ 'relay ready host=' + this.config.host +
272
+ ' topic=' + this.config.topic +
273
+ ' poll=' + String(this.config.pollIntervalMs) + 'ms' +
274
+ (this.config.claimForeground === true ? ' claimForeground=on' : ''),
275
+ )
276
+ }
277
+
278
+ stop() {
279
+ this.waiters.clear()
280
+ this.questions.clear()
281
+ this.announced.clear()
282
+ this.complained.clear()
283
+ this.turns.clear()
284
+ }
285
+
286
+ // ── notification layer ───────────────────────────────────────────────────────────
287
+
288
+ /**
289
+ * The `user-questions/request` listener. Notify, then hand the request back to the chain
290
+ * (the Web UI answerer) — `next()` is what keeps browser answering untouched.
291
+ */
292
+ onRequest(request, next) {
293
+ const questions = Array.isArray(request?.questions) ? request.questions : []
294
+ if (questions.length === 0) return next()
295
+ if (!this.enabled || this.config.pushQuestions !== true) return next()
296
+
297
+ const callId = request.wait?.callId ?? 'pq-' + randomUUID()
298
+ const agentId = request.agent?.id ?? ''
299
+ const sessionId = request.agent?.session?.id ?? agentId
300
+ const timed = request.wait?.timed === true
301
+ void this.announce({ callId, agentId, sessionId, timed, questions })
302
+
303
+ if (this.config.claimForeground !== true) return next()
304
+ return this.firstOf([this.hold(callId, request), Promise.resolve().then(() => next())], request.signal, callId)
305
+ }
306
+
307
+ /** Push + register, fire and forget: a network hiccup must never block a question. */
308
+ async announce({ callId, agentId, sessionId, timed, questions }) {
309
+ const registered = relayQuestions(questions)
310
+ const now = Date.now()
311
+ const previous = this.announced.get(callId)
312
+ if (previous !== undefined && now - previous.ts < 3600_000) {
313
+ // The `tools/pre-execute` fallback usually fires FIRST and can only *guess* `timed` from
314
+ // the tool arguments (`args.timeout`); a timed-mode call that omitted the optional timeout
315
+ // argument is guessed as `false`, and the authoritative `user-questions/request` waterfall
316
+ // (which sees `wait.timed`) lands here second and is deduped. Keep the log honest instead of
317
+ // leaving a wrong `timed=false` line as the only evidence.
318
+ if (previous.timed !== true && timed === true) {
319
+ this.announced.set(callId, { ts: now, timed })
320
+ this.log('question ' + callId + ' timed corrected to true by the request waterfall (the pre-execute guess had no timeout argument)')
321
+ }
322
+ this.questions.set(callId, { questions: registered, timed, ts: now, echoed: false })
323
+ return
324
+ }
325
+ this.announced.set(callId, { ts: now, timed })
326
+ this.sweep()
327
+ this.questions.set(callId, { questions: registered, timed, ts: now, echoed: false })
328
+ this.log(
329
+ 'question ' + callId + ' agent=' + (agentId || '(none)') + ' timed=' + String(timed) +
330
+ ' q=' + JSON.stringify(registered.map((question) => question.id)),
331
+ )
332
+ if (!timed) {
333
+ this.log(
334
+ 'note: ' + callId + ' has no timed wait, so it never enters the session projection — ' +
335
+ 'a phone answer cannot be applied to it (only the Web UI can answer). ' +
336
+ 'Switch tool-ask-user to mode: timed to make phone answering work for late answers.',
337
+ )
338
+ }
339
+ try {
340
+ const result = await this.relay.register({ callId, agentId, questions: registered, ttl: 86400 })
341
+ // Log the success too: without a positive line, "the phone can answer this" is only visible
342
+ // as the *absence* of the 401 line, which is exactly how tonight's 401 hid in plain sight.
343
+ this.log('register ' + callId + ' -> HTTP ' + String(result.status))
344
+ } catch (error) {
345
+ this.log('register failed for ' + callId + ': ' + String(error?.message ?? error))
346
+ }
347
+ try {
348
+ await this.relay.push({
349
+ title: 'DSH 等你选择 [' + String(sessionId).slice(-8) + ']',
350
+ body: this.questionBody(callId, registered, sessionId),
351
+ priority: 4,
352
+ })
353
+ } catch (error) {
354
+ this.log('push failed for ' + callId + ': ' + String(error?.message ?? error))
355
+ }
356
+ }
357
+
358
+ /** The phone renders this body: question, options, answer link, then machine trailers. */
359
+ questionBody(callId, questions, sessionId = '') {
360
+ const first = questions[0] ?? { id: 'q0', question: '', options: [] }
361
+ const labels = (first.options ?? []).filter((label) => typeof label === 'string')
362
+ let body = (first.question || 'DSH 正在等你选择') + (labels.length > 0 ? '\n→ ' + labels.slice(0, 3).join(' / ') : '')
363
+ body += '\n📱 作答: ' + this.answerUrl(callId)
364
+ body += '\n#qid ' + first.id
365
+ if (
366
+ questions.length === 1 &&
367
+ labels.length >= 2 &&
368
+ labels.length <= 3 &&
369
+ labels.every((label) => !/[|=#\n]/.test(label))
370
+ ) {
371
+ body += '\n#opts ' + first.id + '=' + labels.join('|')
372
+ }
373
+ const web = this.webUrl(sessionId)
374
+ if (web.length > 0) body += '\n#web ' + web
375
+ const friendly = this.config.webName.trim()
376
+ if (friendly.length > 0) body += '\n#name ' + friendly.replace(/\n/g, ' ')
377
+ return body
378
+ }
379
+
380
+ answerUrl(callId) {
381
+ const base = (this.config.answerBase || this.config.relayBase).replace(/\/+$/, '')
382
+ return base + '/answer/' + encodeURIComponent(callId)
383
+ }
384
+
385
+ /**
386
+ * `#web` trailer: the last `?token=` URL DSH printed to web.log.
387
+ *
388
+ * With `webBase` set the link is rehosted onto that base (the address the phone can actually
389
+ * reach). With `webBase` empty the logged URL is used as-is (v0.1.1): a fresh install then still
390
+ * ships a web entry instead of silently dropping it — reachable exactly as far as whatever
391
+ * address DSH bound to, which is precisely what `webBase` exists to override.
392
+ */
393
+ /**
394
+ * v0.2.2 - `sessionId` is appended as a **URL fragment** (`...#session-<uuid>`) so opening the
395
+ * link lands on the conversation that produced the notification; our client module
396
+ * (`lib/client.js`) reads it and switches the main view to that session.
397
+ *
398
+ * A fragment rather than a query param, for two measured reasons: the `?token=` entry answers
399
+ * `303 See Other -> Location: ./`, which drops the query string, while browsers carry the
400
+ * fragment across redirects; and the web client has no router, so nothing else reads path or
401
+ * query. Clients that ignore the fragment keep behaving exactly as before.
402
+ */
403
+ webUrl(sessionId = '') {
404
+ const base = this.config.webBase.trim()
405
+ const file = this.config.webLog.length > 0 ? this.config.webLog : join(homedir(), '.dsh', 'web.log')
406
+ try {
407
+ const text = readFileSync(file, 'utf8')
408
+ const matches = [...text.matchAll(/dsh web: (http:\/\/\S+[?&]token=\S+)/g)]
409
+ if (matches.length === 0) return ''
410
+ const logged = matches[matches.length - 1][1]
411
+ const url = base.length === 0
412
+ ? logged
413
+ : base.replace(/\/+$/, '') + '/?token=' + (/[?&]token=(\S+)/.exec(logged)?.[1] ?? '')
414
+ const sid = String(sessionId ?? '').trim()
415
+ // 只接受 `session-<uuid>` 形态:拿不到或形态不对就不拼,绝不产出 `...#` 这种脏链接。
416
+ if (!/^session-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(sid)) return url
417
+ return url + '#' + sid
418
+ } catch (error) {
419
+ this.log('web.log unreadable (' + file + '): ' + String(error?.message ?? error))
420
+ return ''
421
+ }
422
+ }
423
+
424
+ // ── turn-end notification + answer echo (Stop / PostToolUse parity) ──────────────
425
+
426
+ /**
427
+ * `tools/pre-execute` is the PreToolUse seam — a second, ordering-independent trigger for
428
+ * the notification. The waterfall trigger above can be jumped by another answerer that
429
+ * prepends *after* this plugin loads (a later prepend wins the head) and answers without
430
+ * calling `next()`; in that case the `user-questions/request` listener never runs. Both
431
+ * triggers announce the same `exec.callId`, and `announce()` dedupes on it, so the usual
432
+ * case still pushes exactly once (and the waterfall path, which knows `wait.timed`, gets
433
+ * to refresh the entry).
434
+ */
435
+ onPreExecute(exec, next) {
436
+ try {
437
+ const callId = String(exec?.callId ?? '')
438
+ if (callId.length > 0 && this.enabled === true && this.config.pushQuestions === true && !this.questions.has(callId)) {
439
+ const args = exec?.arguments ?? {}
440
+ const agent = exec?.agent
441
+ const common = {
442
+ callId,
443
+ agentId: String(agent?.id ?? ''),
444
+ sessionId: String(agent?.session?.header?.id ?? ''),
445
+ // The tool is the only place the timeout argument is visible before the wait opens;
446
+ // `-1` is the tool's "block forever" sentinel, anything else asks `askTimed`.
447
+ timed: typeof args.timeout === 'number' && args.timeout !== -1,
448
+ }
449
+ if (Array.isArray(args.questions) && args.questions.length > 0) {
450
+ void this.announce({ ...common, questions: args.questions })
451
+ } else if (typeof args.plan === 'string' && args.plan.length > 0) {
452
+ void this.announce({ ...common, questions: [{ id: 'plan', question: args.plan, options: [] }] })
453
+ }
454
+ }
455
+ } catch (error) {
456
+ this.log('pre-execute notify failed: ' + String(error?.message ?? error))
457
+ }
458
+ return next()
459
+ }
460
+
461
+ /**
462
+ * `agent/turn-stopping` is the in-process event behind the Claude-Code `Stop` hook
463
+ * (`dsh-hooks-claude-code/lib/index.js:292`), so "DSH 任务结束" needs no hook script.
464
+ * Fire and forget — a dead relay must never block a turn from ending.
465
+ */
466
+ onTurnStopping(payload) {
467
+ if (this.enabled !== true || this.config.notifyTurns !== true) return
468
+ const session = payload?.agent?.session
469
+ const sessionId = String(session?.header?.id ?? '')
470
+ if (sessionId.length === 0) return
471
+ const key = sessionId + ':' + String(payload?.turn ?? '')
472
+ if (this.turns.has(key)) return
473
+ this.turns.set(key, Date.now())
474
+ this.sweepTurns()
475
+
476
+ let body = '目录: ' + String(session?.header?.cwd ?? '')
477
+ const web = this.webUrl(sessionId)
478
+ if (web.length > 0) body += '\n#web ' + web
479
+ const friendly = this.config.webName.trim()
480
+ if (friendly.length > 0) body += '\n#name ' + friendly.replace(/\n/g, ' ')
481
+ void this.relay
482
+ .push({ title: 'DSH 任务结束 [' + sessionId.slice(-8) + ']', body, priority: 3 })
483
+ .then((result) => {
484
+ if (result?.status !== 200) this.log('turn push -> HTTP ' + String(result?.status))
485
+ })
486
+ .catch((error) => this.log('turn push failed: ' + String(error?.message ?? error)))
487
+ }
488
+
489
+ /**
490
+ * `tools/post-execute` is the PostToolUse seam. For a question we pushed: an answered
491
+ * result echoes `#ans` so the phone clears the card; a `pending` result (timed wait expired
492
+ * unanswered) must NOT clear it, because that question is still answerable from the phone.
493
+ */
494
+ onPostExecute(exec, result, next) {
495
+ try {
496
+ const callId = String(exec?.callId ?? '')
497
+ const entry = this.questions.get(callId)
498
+ if (entry !== undefined && this.config.echoAnswers === true) {
499
+ const value = resultValue(result)
500
+ if (value?.pending === true) {
501
+ this.log('question ' + callId + ' returned pending; the phone card stays answerable')
502
+ } else if (entry.echoed !== true) {
503
+ entry.echoed = true
504
+ void this.echo(callId, Array.isArray(value?.answers) ? value.answers : [])
505
+ }
506
+ }
507
+ } catch (error) {
508
+ this.log('post-execute echo failed: ' + String(error?.message ?? error))
509
+ }
510
+ return next()
511
+ }
512
+
513
+ sweepTurns() {
514
+ const now = Date.now()
515
+ for (const [key, ts] of this.turns) if (now - ts > 6 * 3600_000) this.turns.delete(key)
516
+ }
517
+
518
+ // ── foreground claim (opt-in only) ───────────────────────────────────────────────
519
+
520
+ /** Register a waiter the poll loop can settle with the phone's answer. */
521
+ hold(callId, request) {
522
+ let resolvePhone = () => {}
523
+ const promise = new Promise((resolve) => {
524
+ resolvePhone = resolve
525
+ })
526
+ this.waiters.set(callId, {
527
+ resolve: (answers) => resolvePhone({ answers }),
528
+ agent: request.agent,
529
+ since: Date.now(),
530
+ })
531
+ return promise
532
+ }
533
+
534
+ /**
535
+ * Resolve with the first fulfilment; reject once every candidate failed (keeping the most
536
+ * informative error) or when the question is aborted.
537
+ */
538
+ firstOf(candidates, signal, callId) {
539
+ return new Promise((resolve, reject) => {
540
+ let settled = false
541
+ const errors = []
542
+ const finish = (run) => {
543
+ if (settled) return
544
+ settled = true
545
+ this.waiters.delete(callId)
546
+ try {
547
+ signal?.removeEventListener?.('abort', onAbort)
548
+ } catch {
549
+ /* not an EventTarget */
550
+ }
551
+ run()
552
+ }
553
+ const onAbort = () => finish(() => reject(signal?.reason ?? new Error('question aborted')))
554
+ if (signal?.aborted) return onAbort()
555
+ signal?.addEventListener?.('abort', onAbort, { once: true })
556
+ for (const candidate of candidates) {
557
+ Promise.resolve(candidate).then(
558
+ (value) => finish(() => resolve(value)),
559
+ (error) => {
560
+ if (settled) return
561
+ errors.push(error)
562
+ if (errors.length === candidates.length) finish(() => reject(preferCodedError(errors)))
563
+ },
564
+ )
565
+ }
566
+ })
567
+ }
568
+
569
+ // ── late-answer layer ────────────────────────────────────────────────────────────
570
+
571
+ async pollOnce() {
572
+ if (this.polling) return
573
+ // Same inactive-context rule as the mode switch: the interval is disposed with the fiber,
574
+ // but a tick already in flight can outlive it and `ctx.userQuestions` would then throw.
575
+ if (!fiberIsActive(this.ctx)) return
576
+ this.polling = true
577
+ try {
578
+ const items = await this.relay.pending()
579
+ const signature = items.map((item) => String(item?.callId ?? '?')).join(' ')
580
+ if (signature !== this.lastQueue) {
581
+ this.lastQueue = signature
582
+ if (items.length > 0) {
583
+ this.log('queue: ' + String(items.length) + ' item(s) [' + items.map((item) => String(item?.callId ?? '?').slice(0, 12)).join(' ') + ']')
584
+ }
585
+ }
586
+ for (const item of items) {
587
+ try {
588
+ await this.deliver(item)
589
+ } catch (error) {
590
+ // No ack: the relay keeps it for another hour and we retry next tick.
591
+ this.complain('defer:' + String(item?.callId ?? '?'), 'delivery deferred: ' + String(error?.message ?? error))
592
+ continue
593
+ }
594
+ try {
595
+ await this.relay.ack([item.callId])
596
+ } catch (error) {
597
+ this.log('ack failed for ' + String(item?.callId ?? '?') + ': ' + String(error?.message ?? error))
598
+ }
599
+ }
600
+ } catch (error) {
601
+ this.log('poll failed: ' + String(error?.message ?? error))
602
+ } finally {
603
+ this.polling = false
604
+ }
605
+ }
606
+
607
+ /**
608
+ * Deliver one queued answer. Returns normally = settled (the caller acks); throws = keep it
609
+ * queued and retry next tick (never drop an answer we could not hand over yet).
610
+ */
611
+ async deliver(item) {
612
+ const callId = String(item?.callId ?? '')
613
+ if (callId.length === 0) return
614
+ // `ctx.userQuestions.answer` below is a service read: on a fiber that is being replaced it
615
+ // throws instead of returning, and the throw escapes as an unhandled rejection. Bail out so
616
+ // the relay keeps the answer queued for the replacement fiber's next tick.
617
+ if (!fiberIsActive(this.ctx)) throw new Error('fiber inactive; keeping the answer queued')
618
+ const answers = this.padAnswers(callId, item)
619
+
620
+ const waiter = this.waiters.get(callId)
621
+ if (waiter !== undefined) {
622
+ this.waiters.delete(callId)
623
+ waiter.resolve(answers)
624
+ this.log('answered (foreground) ' + callId)
625
+ await this.echo(callId, answers)
626
+ return
627
+ }
628
+
629
+ const known = this.questions.get(callId)
630
+ if (known !== undefined && known.timed !== true) {
631
+ this.log(
632
+ 'cannot apply the phone answer to ' + callId + ': the question has no timed wait, ' +
633
+ 'so the agent never gave up the foreground and there is nothing left to answer.',
634
+ )
635
+ return
636
+ }
637
+
638
+ const agent = this.liveRoot(item?.agentId)
639
+ if (agent === undefined) {
640
+ this.complain(callId, 'no live root agent for ' + String(item?.agentId ?? '') + '; keeping the answer queued')
641
+ throw new Error('agent not live: ' + String(item?.agentId ?? ''))
642
+ }
643
+ let accepted
644
+ try {
645
+ accepted = this.ctx.userQuestions.answer(agent, callId, { answers })
646
+ } catch (error) {
647
+ if (error?.code === 'CALLER_NOT_LIVE' || error?.code === 'DELEGATED_CALLER') {
648
+ this.complain(callId, 'agent not answerable (' + String(error.code) + '); keeping the answer queued')
649
+ throw error
650
+ }
651
+ this.log('answer rejected for ' + callId + ' (' + String(error?.code ?? error?.message ?? error) + '); dropping')
652
+ return
653
+ }
654
+ if (accepted === false) {
655
+ // `answer()` only accepts a *continued* question (the projection's own filter). A phone
656
+ // answer that lands while the foreground window is still open must NOT be thrown away:
657
+ // hold it in the relay queue and retry — it becomes answerable the moment the wait ends.
658
+ const view = this.questionState(agent, callId)
659
+ if (view.settled === true) {
660
+ this.log('question ' + callId + ' is settled; dropping the queued answer')
661
+ return
662
+ }
663
+ this.complain(
664
+ callId,
665
+ 'not answerable yet (' + (view.available ? 'state=' + String(view.state) : 'projection seam unavailable') +
666
+ '); keeping the phone answer queued and retrying',
667
+ )
668
+ throw new Error('question not continued yet: ' + callId)
669
+ }
670
+ this.log('answered (continued) ' + callId)
671
+ if (known !== undefined) known.echoed = true
672
+ await this.echo(callId, answers)
673
+ }
674
+
675
+ /**
676
+ * Where the question sits in the session projection. The registered unit's state is
677
+ * `{ timed, questions: { active, settled } }` (the `dsh-user-questions` fold), and
678
+ * `answer()` accepts only `state === 'continued'`.
679
+ */
680
+ questionState(agent, callId) {
681
+ const seam = this.ctx.get('sessionProjections')
682
+ if (seam?.stateOf === undefined) return { available: false, state: undefined, settled: false }
683
+ try {
684
+ const state = seam.stateOf(agent?.session, 'userQuestions')
685
+ const active = (state?.questions?.active ?? []).find((item) => item?.callId === callId)
686
+ const settled = (state?.questions?.settled ?? []).some((item) => item?.callId === callId)
687
+ return { available: true, state: active?.state, settled }
688
+ } catch (error) {
689
+ this.log('projection read failed for ' + callId + ': ' + String(error?.message ?? error))
690
+ return { available: false, state: undefined, settled: false }
691
+ }
692
+ }
693
+
694
+ /** The exact live root agent owning this question, by agent id then by session id. */
695
+ liveRoot(agentId) {
696
+ const id = String(agentId ?? '')
697
+ if (id.length === 0) return undefined
698
+ const registry = this.ctx.get('agents') ?? this.ctx.agents
699
+ if (registry === undefined) return undefined
700
+ const exact = registry.get?.(id)
701
+ if (exact !== undefined) return exact
702
+ return (registry.roots?.() ?? []).find((agent) => agent?.session?.id === id)
703
+ }
704
+
705
+ /** Pad a partial batch (the phone's one-tap answer sends a single item) to the full set. */
706
+ padAnswers(callId, item) {
707
+ const known =
708
+ this.questions.get(callId)?.questions ?? (item?.qids ?? []).map((id) => ({ id, question: '', options: [] }))
709
+ const given = Array.isArray(item?.answers) ? item.answers : []
710
+ const byId = new Map()
711
+ for (const answer of given) {
712
+ if (answer !== null && typeof answer === 'object' && typeof answer.id === 'string') byId.set(answer.id, answer)
713
+ }
714
+ if (known.length === 0) return given
715
+ return known.map((question) => {
716
+ const answer = byId.get(question.id)
717
+ if (answer === undefined) return { id: question.id, selected: [] }
718
+ return {
719
+ id: question.id,
720
+ selected: Array.isArray(answer.selected) ? answer.selected : [],
721
+ ...(typeof answer.custom === 'string' ? { custom: answer.custom } : {}),
722
+ }
723
+ })
724
+ }
725
+
726
+ /** Silent `#ans` echo on this host's own topic (the relay's echo is hard-wired to `dsh`). */
727
+ async echo(callId, answers) {
728
+ if (this.config.echoAnswers !== true) return
729
+ const summary = answerSummary(answers)
730
+ try {
731
+ await this.relay.push({
732
+ title: 'DSH 已答',
733
+ body: '#ans ' + callId + ' ' + (summary.length > 0 ? summary : '已作答'),
734
+ priority: 1,
735
+ })
736
+ } catch (error) {
737
+ this.log('echo failed for ' + callId + ': ' + String(error?.message ?? error))
738
+ }
739
+ }
740
+
741
+ complain(callId, message) {
742
+ const now = Date.now()
743
+ const previous = this.complained.get(callId) ?? 0
744
+ if (now - previous < 300_000) return
745
+ this.complained.set(callId, now)
746
+ this.log(callId + ': ' + message)
747
+ }
748
+
749
+ /** Drop bookkeeping older than a day (the relay queue TTL is 1 h). */
750
+ sweep() {
751
+ const now = Date.now()
752
+ for (const [callId, entry] of this.announced) if (now - entry.ts > 86_400_000) this.announced.delete(callId)
753
+ for (const [callId, ts] of this.complained) if (now - ts > 86_400_000) this.complained.delete(callId)
754
+ for (const [callId, entry] of this.questions) {
755
+ if (this.waiters.has(callId)) continue
756
+ if (now - entry.ts > 86_400_000) this.questions.delete(callId)
757
+ }
758
+ }
759
+ }
760
+
761
+ /** Merge what the profile patch supplied over our defaults (never trust Config to be applied). */
762
+ function resolveConfig(config) {
763
+ const resolved = { ...DEFAULTS }
764
+ for (const [key, value] of Object.entries(config ?? {})) {
765
+ if (value === undefined || value === null) continue
766
+ if (typeof DEFAULTS[key] === 'string') resolved[key] = String(value)
767
+ else if (typeof DEFAULTS[key] === 'number') resolved[key] = Number(value)
768
+ else if (typeof DEFAULTS[key] === 'boolean') resolved[key] = value === true || value === 'true'
769
+ else resolved[key] = value
770
+ }
771
+ return resolved
772
+ }
773
+
774
+ /** The credentials file this profile would read, for diagnostics and for the `pair` command. */
775
+ export function credentialFilePath(config = {}, env = process.env) {
776
+ return credentialsPath(config, env)
777
+ }
778
+
779
+ /**
780
+ * Resolve the effective plugin configuration, secrets included.
781
+ *
782
+ * built-in defaults → credentials file → profile/overlay config → environment variables
783
+ *
784
+ * The credentials file only ever *fills in* values: an explicit profile config or environment
785
+ * variable wins over it, and a missing file contributes nothing at all.
786
+ */
787
+ export function resolvePluginConfig(config = {}, { env = process.env, readFile = readFileSync } = {}) {
788
+ const path = credentialFilePath(config, env)
789
+ const file = loadCredentials(path, { readFile })
790
+ // Credentials outrank the built-in defaults; the profile/overlay config outranks the credentials,
791
+ // so anything the operator wrote down explicitly still wins over the file.
792
+ //
793
+ // 2026-10-12 fix (production cutover caught this): at `apply()` time `config` has ALREADY been
794
+ // through schema validation, so its defaults are *present* (`host: ''`, `hostToken: ''`,
795
+ // `ntfyToken: ''`, …). Spreading that over the credentials file therefore erased every value the
796
+ // file contributed → the plugin logged "relay disabled" on a perfectly good credentials file,
797
+ // with the old hook already retired (= no phone notifications at all). An empty string now counts
798
+ // as "not set", so the file can fill it in. `false` stays meaningful (never dropped).
799
+ const fromCredentials = pickConfigKeys(file.value)
800
+ const explicit = {}
801
+ for (const [key, value] of Object.entries(config)) {
802
+ if (value !== '' && value !== undefined && value !== null) explicit[key] = value
803
+ }
804
+ const resolved = resolveConfig({ ...fromCredentials, ...explicit })
805
+
806
+ const envOverride = (key, name) => {
807
+ const overridden = typeof env[name] === 'string' ? env[name].trim() : ''
808
+ return overridden.length > 0 ? overridden : resolved[key]
809
+ }
810
+ return {
811
+ path,
812
+ file,
813
+ resolved: {
814
+ ...resolved,
815
+ hostToken: envOverride('hostToken', resolved.hostTokenEnv),
816
+ regToken: envOverride('regToken', resolved.regTokenEnv),
817
+ ntfyToken: envOverride('ntfyToken', resolved.ntfyTokenEnv),
818
+ provisionToken: envOverride('provisionToken', resolved.provisionTokenEnv),
819
+ },
820
+ }
821
+ }
822
+
823
+ /** Normalize the legacy `withEnv` shape (a resolved config + the process environment). */
824
+ export function withEnv(config, env = process.env) {
825
+ const resolved = resolvePluginConfig(config, { env })
826
+ return { ...resolved.resolved, credentialsPath: resolved.path }
827
+ }
828
+
829
+ /** Only the config keys a credentials file can legitimately carry (never booleans/secrets env names). */
830
+ export const CREDENTIAL_CONFIG_KEYS = [
831
+ 'relayBase',
832
+ 'answerBase',
833
+ 'topic',
834
+ 'host',
835
+ 'hostToken',
836
+ 'ntfyToken',
837
+ 'phoneToken',
838
+ 'webBase',
839
+ 'webName',
840
+ 'webLog',
841
+ 'pollIntervalMs',
842
+ 'credentialsFile',
843
+ 'regToken',
844
+ 'provisionBase',
845
+ 'provisionToken',
846
+ ]
847
+
848
+ function pickConfigKeys(values) {
849
+ const picked = {}
850
+ for (const key of CREDENTIAL_CONFIG_KEYS) if (values[key] !== undefined) picked[key] = values[key]
851
+ return picked
852
+ }
853
+
854
+ /** Log credentials-file trouble once per boot; a missing file is not trouble. */
855
+ function reportCredentials(file, log) {
856
+ if (file.error !== undefined) log('credentials ignored: ' + file.error)
857
+ }
858
+
859
+ // ── 手机作答模式开关 (v0.2.1) ─────────────────────────────────────────────────────────
860
+ // The Web settings row (lib/client.js) writes the volatile `askUserMode` above over the
861
+ // settings RPC; the host reacts and rewrites tool-ask-user's OWN config through
862
+ // `ctx.configEditor.edit` — mode/timeout are NOT volatile there (dsh-tool-ask-user Config),
863
+ // so the settings RPC cannot touch them directly. A non-volatile write hot-restarts just
864
+ // that plugin's fiber (cordis-plugin-loader), so the timed tool re-registers WITHOUT a
865
+ // host restart. Before every write the profile patch file is copied to `<patchPath>.bak-<ms>`
866
+ // (the dsh-app-boot sanitizeProfile naming convention). The timed variant only affects
867
+ // sessions created after the switch — tool variants are pinned at session creation; the
868
+ // row says so. A same-id home patch / CLI overlay makes the write throw
869
+ // "…is overridden by a home patch or command-line overlay" (dsh-config-editor edit():122);
870
+ // the message is mirrored into the volatile `askUserStatus` for the row to display.
871
+ //
872
+ // v0.2.1 hotfix rules (production P0 — 0.2.0 downgraded a live timed production to legacy):
873
+ // 1. NEVER downgrade: only an explicit `timed` desire may ever write; unset or `legacy`
874
+ // is fully inert (no file write, no backup).
875
+ // 2. At most ONE successful tool-ask-user write per process (`done`): an external repair
876
+ // (file restore + reconcile) must not make a live process downgrade again.
877
+ // 3. The write is deferred outside the current HMR transaction (`setTimeout 0`): a
878
+ // volatile-update fires inside the settings RPC's transaction, and a nested
879
+ // configEditor.edit throws "HMR transactions cannot be nested".
880
+ // 4. The row displays the ACTUAL tool state, mirrored into volatile `askUserActual`.
881
+ // 5. Status/actual mirrors are IN-MEMORY ONLY (defect 3: 0.2.0 persisted the status string
882
+ // into the user's cordis.patch.yml via configEditor.edit). Runtime volatile fields are
883
+ // frozen cosmokit refs whose only setter is the global symbol `cosmokit.volatile.write`
884
+ // (createVolatile; updateVolatile is just `target[write](source.get())`). The settings
885
+ // describe() reads those live refs, so the Web row sees them without any file write.
886
+ // The ONLY file write allowed is the one tool-ask-user upgrade.
887
+
888
+ /** Read a possibly-volatile config value (a Volatile ref at runtime, a plain value in tests). */
889
+ export function volatileValue(value, fallback) {
890
+ if (value === undefined || value === null) return fallback
891
+ if (typeof value === 'object' && typeof value.get === 'function') {
892
+ const live = value.get()
893
+ return live === undefined || live === null ? fallback : live
894
+ }
895
+ return value
896
+ }
897
+
898
+ /**
899
+ * Write an IN-MEMORY volatile config value — never through configEditor (defect 3: status
900
+ * strings must not land in the user's cordis.patch.yml). At runtime a `.volatile()` field
901
+ * is a frozen cosmokit ref whose only setter is the global symbol
902
+ * `cosmokit.volatile.write` (cosmokit createVolatile/updateVolatile); the settings service
903
+ * reads the live ref, so the Web row sees the update on its next describe. Plain values
904
+ * (tests, hosts without volatile wiring) are assigned back so callers still observe it; a
905
+ * ref without the symbol keeps its value and the log line still carries the message.
906
+ */
907
+ export function setVolatileValue(container, key, value) {
908
+ const current = container[key]
909
+ if (current && typeof current === 'object' && typeof current.get === 'function') {
910
+ const write = Symbol.for('cosmokit.volatile.write')
911
+ if (typeof current[write] === 'function') current[write](value)
912
+ return
913
+ }
914
+ container[key] = value
915
+ }
916
+
917
+ /**
918
+ * True when this plugin's own fiber is still alive enough to touch its services.
919
+ *
920
+ * A cordis plugin fiber clears `fiber.store` (and nulls it out) while it is unloaded —
921
+ * HMR / replacement / a market-driven restart — but `fiber.inject` stays intact, so the
922
+ * context proxy THROWS `cannot get required service "..." in inactive context` instead of
923
+ * handing back `undefined`. Guarding with `if (!ctx.configEditor)` therefore cannot see the
924
+ * difference: the access itself is what throws. Reading one cheap `ctx` member is itself the
925
+ * test, because it goes through the same proxy (and `fiber.state` is a plain field, so it does
926
+ * not). `ACTIVE` is 2; UNLOADING is 5 — anything but 2 means "do not touch services".
927
+ */
928
+ export function fiberIsActive(ctx) {
929
+ try {
930
+ const fiber = ctx && ctx.fiber
931
+ if (!fiber) return true // a bare test host without fibers: behave as before
932
+ return fiber.state === 2
933
+ } catch {
934
+ return false
935
+ }
936
+ }
937
+
938
+ /** Whether one tool definition is the timed `ask_user_question` (dsh-user-questions projection.js:89). */
939
+ export function isTimedAskUserQuestion(tool) {
940
+ if (!tool || tool.name !== 'ask_user_question') return false
941
+ const parameters = tool.parameters
942
+ const properties = parameters && typeof parameters === 'object' ? parameters.properties : undefined
943
+ return !!properties && typeof properties === 'object' && !Array.isArray(properties) && 'timeout' in properties
944
+ }
945
+
946
+ /** `<patchPath>.bak-<ms>` with a collision ordinal — the dsh-app-boot sanitizeProfile convention. */
947
+ export function askUserBackupPath(patchPath, now = Date.now, exists = existsSync) {
948
+ const base = `${patchPath}.bak-${now()}`
949
+ let candidate = base
950
+ let ordinal = 0
951
+ while (exists(candidate)) candidate = `${base}-${++ordinal}`
952
+ return candidate
953
+ }
954
+
955
+ /** The next raw config for the tool-ask-user entry given the desired mode. */
956
+ export function nextAskUserConfig(current, desired) {
957
+ const next = { ...(current ?? {}) }
958
+ next.mode = desired
959
+ if (desired === 'timed') next.timeout = 120
960
+ return next
961
+ }
962
+
963
+ /**
964
+ * What one sync pass should do, given the desired mode and the live tool definition.
965
+ * v0.2.1: the switch only ever UPGRADES to timed. An unset or `legacy` desire is `inert`
966
+ * — the 0.2.0 P0 treated "user never opted in" as "user wants legacy" and downgraded a
967
+ * live timed production.
968
+ */
969
+ export function askUserSyncPlan(desired, tool) {
970
+ if (desired !== 'timed') return { action: 'inert', desired }
971
+ if (tool === undefined) return { action: 'wait' }
972
+ const actual = isTimedAskUserQuestion(tool) ? 'timed' : 'legacy'
973
+ if (actual === 'timed') return { action: 'noop', actual }
974
+ return { action: 'edit', actual, desired }
975
+ }
976
+
977
+ /** Bridges the volatile desire to the tool-ask-user entry's real config. */
978
+ export class AskUserModeSwitch {
979
+ /**
980
+ * @param ctx - the plugin context (needs `tools` + `configEditor`; `profileContext` optional).
981
+ * @param config - the validated plugin config (volatile fields are live refs).
982
+ * @param log - logging sink.
983
+ */
984
+ constructor(ctx, config, log) {
985
+ this.ctx = ctx
986
+ this.config = config
987
+ this.log = log
988
+ this.busy = false
989
+ /** One successful tool-ask-user write per process — an external repair is never re-downgraded. */
990
+ this.done = false
991
+ /** Coalesces deferred (outside-HMR-transaction) sync runs into one. */
992
+ this.scheduled = false
993
+ /** Last outcome mirrored into `askUserStatus` (undefined = never written). */
994
+ this.lastStatus = undefined
995
+ /** Last actual state mirrored into `askUserActual` (undefined = never written). */
996
+ this.lastActual = undefined
997
+ }
998
+
999
+ /** The mode the settings row asked for; `undefined` = the user never opted in (inert). */
1000
+ desired() {
1001
+ return volatileValue(this.config.askUserMode, undefined)
1002
+ }
1003
+
1004
+ /**
1005
+ * Run one sync on the next macrotask: volatile-update and tools/change can fire inside
1006
+ * an HMR transaction, where a nested configEditor.edit throws
1007
+ * "HMR transactions cannot be nested".
1008
+ */
1009
+ scheduleSync() {
1010
+ if (this.scheduled) return
1011
+ this.scheduled = true
1012
+ setTimeout(() => {
1013
+ this.scheduled = false
1014
+ // A rejected promise with no handler is a fatal load failure in the host process
1015
+ // (installFailLoud -> unhandledRejection -> exit(1)). Never leave one unattached.
1016
+ Promise.resolve(this.sync()).catch(() => {})
1017
+ }, 0)
1018
+ }
1019
+
1020
+ /** Compare desire vs live tool; rewrite tool-ask-user's config only to upgrade to timed. Idempotent. */
1021
+ async sync() {
1022
+ if (this.busy) return
1023
+ const ctx = this.ctx
1024
+ // The unload race is the real killer: scheduleSync() runs `setTimeout(0)`, so a fiber
1025
+ // replacement between the call and the callback leaves `this.ctx` pointing at a fiber
1026
+ // whose store is already gone. Test liveness FIRST (cheap, and it is the same proxy), and
1027
+ // still catch: an unhandled rejection here is a fatal load failure in the host process.
1028
+ if (!fiberIsActive(ctx)) return
1029
+ if (!ctx.configEditor || !ctx.tools) return
1030
+ this.busy = true
1031
+ try {
1032
+ const tool = ctx.tools.get('ask_user_question')
1033
+ const actual = isTimedAskUserQuestion(tool) ? 'timed' : 'legacy'
1034
+ // The display always follows reality, never the desire (P0: they can disagree).
1035
+ this.writeActual(actual)
1036
+ // One successful write per process: after a repair (file restore + reconcile),
1037
+ // a live process must not treat the fixed state as drift and downgrade again.
1038
+ if (this.done) return
1039
+ const plan = askUserSyncPlan(this.desired(), tool)
1040
+ if (plan.action === 'inert') return
1041
+ if (plan.action === 'wait') return
1042
+ if (plan.action === 'noop') {
1043
+ this.writeStatus('')
1044
+ return
1045
+ }
1046
+ // Last gate: only an upgrade to timed may ever reach the file (P0 regression guard).
1047
+ if (plan.desired !== 'timed') {
1048
+ this.log('手机作答模式:拒绝写入——只允许升到 timed,永不降级')
1049
+ return
1050
+ }
1051
+ const entry = ctx.configEditor.entries().find((row) => row.options.id === 'tool-ask-user')
1052
+ if (entry === undefined) {
1053
+ this.writeStatus('未找到 tool-ask-user 配置项:它可能被停用或改名,手机作答模式未生效')
1054
+ return
1055
+ }
1056
+ const patchPath = ctx.profileContext ? ctx.profileContext.patchPath : undefined
1057
+ if (patchPath && existsSync(patchPath)) {
1058
+ const backup = askUserBackupPath(patchPath)
1059
+ copyFileSync(patchPath, backup)
1060
+ this.log('手机作答模式:' + plan.actual + ' → ' + plan.desired + ',已备份 ' + backup)
1061
+ } else {
1062
+ this.log('手机作答模式:' + plan.actual + ' → ' + plan.desired)
1063
+ }
1064
+ await ctx.configEditor.edit(entry, (current) => nextAskUserConfig(current, plan.desired))
1065
+ const landed = isTimedAskUserQuestion(ctx.tools.get('ask_user_question'))
1066
+ if (landed) {
1067
+ this.done = true
1068
+ this.log('手机作答模式已切换为 timed(新会话立即生效,已开老会话保持旧行为)')
1069
+ this.writeStatus('')
1070
+ this.writeActual('timed')
1071
+ } else {
1072
+ this.writeStatus('写入后工具状态未收敛,请检查宿主日志')
1073
+ }
1074
+ } catch (error) {
1075
+ const message = String(error && error.message ? error.message : error)
1076
+ this.log('手机作答模式切换失败:' + message)
1077
+ this.writeStatus(message)
1078
+ } finally {
1079
+ this.busy = false
1080
+ }
1081
+ }
1082
+
1083
+ /** Mirror the outcome into the volatile `askUserStatus` IN MEMORY (never persisted). */
1084
+ writeStatus(message) {
1085
+ if (message === '' && (this.lastStatus === undefined || this.lastStatus === '')) return
1086
+ this.lastStatus = message
1087
+ setVolatileValue(this.config, 'askUserStatus', message)
1088
+ }
1089
+
1090
+ /** Mirror the ACTUAL tool state into the volatile `askUserActual` IN MEMORY (never persisted). */
1091
+ writeActual(actual) {
1092
+ if (this.lastActual === actual) return
1093
+ this.lastActual = actual
1094
+ setVolatileValue(this.config, 'askUserActual', actual)
1095
+ }
1096
+
1097
+ /** Wire the triggers: volatile desire changes, tool registration changes, and boot. */
1098
+ start() {
1099
+ const ctx = this.ctx
1100
+ // A dead/inactive fiber makes `ctx.configEditor` THROW (not return undefined) — the old
1101
+ // `!ctx.configEditor` test never fired on that path, which is how a market restart killed
1102
+ // the whole host process. Also guards the two listeners below, which fire asynchronously.
1103
+ if (!fiberIsActive(ctx)) return
1104
+ if (!ctx.configEditor || !ctx.tools) return
1105
+ ctx.on('loader/volatile-update', (paths) => {
1106
+ const touched = Array.isArray(paths) && paths.some((path) => Array.isArray(path) && path[0] === 'askUserMode')
1107
+ if (touched) this.scheduleSync()
1108
+ })
1109
+ // Activation order: tool-ask-user may register after this plugin, and any later
1110
+ // registration change re-checks the plan (dsh-tools emits `tools/change`).
1111
+ ctx.on('tools/change', () => this.scheduleSync())
1112
+ this.scheduleSync()
1113
+ }
1114
+ }
1115
+
1116
+ // ---------------------------------------------------------------------------
1117
+ // 自助通行证发号(v0.3.0)
1118
+ //
1119
+ // 中继上的 dsh-provd(见仓库 relay/README.md)是「一人一 topic 一对凭据」的签发口:
1120
+ // 网页设置行经 settings RPC 写 volatile 的 provisionAction/provisionName/provisionSeq,
1121
+ // 这里观察到就跑一次 HTTP,把结果镜像回 provisionResult/provisionList/provisionDone。
1122
+ // 发号成功顺手把 topic + 只写 token + 只读 token 写回插件自己的凭据文件(先备份),
1123
+ // 因为 resolvePluginConfig 的分层是「默认 < 凭据文件 < profile < env」——写文件就够了,
1124
+ // 不必碰 configEditor(也就不会撞 HMR 事务嵌套)。重启本插件 fiber 后生效。
1125
+
1126
+ /** 发号服务地址拼接:去掉多余斜杠,避免出现 //issue。 */
1127
+ export function provisionUrl(base, path) {
1128
+ const b = String(base || '').trim().replace(/\/+$/, '')
1129
+ return b + '/' + String(path || '').replace(/^\/+/, '')
1130
+ }
1131
+
1132
+ /**
1133
+ * 通行证文本(App v1.33.0 起解析):topic 是多人隔离的关键,只有一串 token 的老通行证
1134
+ * 在 App 里仍按默认 topic 处理,所以这里必须把三项都带上。
1135
+ */
1136
+ export function passText({ relayBase, topic, readToken } = {}) {
1137
+ const parts = []
1138
+ if (relayBase) parts.push('base=' + relayBase)
1139
+ if (topic) parts.push('topic=' + topic)
1140
+ if (readToken) parts.push('token=' + readToken)
1141
+ return 'dshpush://v1' + (parts.length ? '?' + parts.join('&') : '')
1142
+ }
1143
+
1144
+ /** 本机名 → ntfy 允许的 topic 名(^[a-z0-9][a-z0-9-]{1,30}$);中文/空名退化成 dsh-local。 */
1145
+ export function provisionNameSuggest(config = {}) {
1146
+ const raw = String(config.host || '').trim() || String(config.webName || '').trim()
1147
+ const cleaned = raw.toLowerCase()
1148
+ .replace(/[^a-z0-9-]+/g, '-')
1149
+ .replace(/^-+/, '')
1150
+ .replace(/-+/g, '-')
1151
+ .slice(0, 31)
1152
+ .replace(/-+$/, '')
1153
+ if (/^[a-z0-9][a-z0-9-]{1,30}$/.test(cleaned)) {
1154
+ const named = cleaned.startsWith('dsh-') ? cleaned : ('dsh-' + cleaned).slice(0, 31).replace(/-+$/, '')
1155
+ return /^[a-z0-9][a-z0-9-]{1,30}$/.test(named) ? named : 'dsh-local'
1156
+ }
1157
+ return 'dsh-local'
1158
+ }
1159
+
1160
+ /** 一次发号调用(fetch 可打桩):返回 {ok,data} 或 {ok:false,error:给人看的中文}。 */
1161
+ export async function provisionCall({ base, token, path, body, fetchImpl = globalThis.fetch } = {}) {
1162
+ if (!token) return { ok: false, error: '缺少发号密钥:填 provisionToken 或设环境变量 DSH_PUSH_PROVISION_TOKEN' }
1163
+ if (typeof fetchImpl !== 'function') return { ok: false, error: '这个 Node 运行时没有 fetch,无法发号' }
1164
+ let res
1165
+ try {
1166
+ res = await fetchImpl(provisionUrl(base, path), {
1167
+ method: path === 'list' ? 'GET' : 'POST',
1168
+ headers: { Authorization: 'Bearer ' + token, 'Content-Type': 'application/json' },
1169
+ body: path === 'list' ? undefined : JSON.stringify(body || {}),
1170
+ })
1171
+ } catch (error) {
1172
+ return { ok: false, error: '发号服务不可达:' + String(error && error.message ? error.message : error) }
1173
+ }
1174
+ let text = ''
1175
+ try { text = String(await res.text()) } catch { text = '' }
1176
+ let data = null
1177
+ try { data = JSON.parse(text) } catch { data = null }
1178
+ if (!res.ok) {
1179
+ const why = data && data.error ? data.error : text.slice(0, 120)
1180
+ return { ok: false, error: '发号服务返回 ' + res.status + (why ? ':' + why : '') }
1181
+ }
1182
+ return { ok: true, data: data === null ? {} : data }
1183
+ }
1184
+
1185
+ /**
1186
+ * 把发到的凭据写回插件自己的凭据文件(先 copyFileSync 备份)。只动 topic / ntfyToken /
1187
+ * phoneToken 三项,其它字段原样保留;文件不存在时按目录建好再写。
1188
+ */
1189
+ export function writeCredentialsPatch(path, patch, deps = {}) {
1190
+ const read = deps.readFile || readFileSync
1191
+ const copy = deps.copyFile || copyFileSync
1192
+ const exists = deps.exists || existsSync
1193
+ const mkdir = deps.mkdir || mkdirSync
1194
+ const write = deps.write || writeFileSync
1195
+ const rename = deps.rename || renameSync
1196
+ const stamp = deps.stamp || (() => new Date().toISOString().replace(/[:.]/g, '-'))
1197
+ const dirOf = (p) => String(p).split(/[\\/]/).slice(0, -1).join('/')
1198
+ let current = {}
1199
+ const existed = exists(path)
1200
+ if (existed) {
1201
+ copy(path, path + '.bak-' + stamp())
1202
+ try { current = JSON.parse(read(path, 'utf8')) || {} } catch { current = {} }
1203
+ } else {
1204
+ try { mkdir(dirOf(path), { recursive: true }) } catch { /* 目录已存在 */ }
1205
+ }
1206
+ const next = { ...current, v: current.v || '1' }
1207
+ for (const [key, value] of Object.entries(patch || {})) {
1208
+ if (typeof value === 'string' && value !== '') next[key] = value
1209
+ }
1210
+ const tmp = path + '.tmp-' + stamp()
1211
+ write(tmp, JSON.stringify(next, null, 2) + '\n')
1212
+ rename(tmp, path)
1213
+ return { path, created: !existed, keys: Object.keys(patch || {}) }
1214
+ }
1215
+
1216
+ /**
1217
+ * 面板镜像的**进程级**暂存(2026-10-17 实测出来的坑):浏览器经 settings RPC 写 volatile 字段
1218
+ * 会被持久化进 profile 的 cordis.patch.yml(实测文件里出现 provisionName/provisionAction/
1219
+ * provisionSeq 三行),补丁一变宿主就热重启本插件 fiber,内存里的 volatile 镜像当场清零 ——
1220
+ * 用户看到的是「结果闪一下自己就没了」。fiber 重启不换进程 ⇒ 把最后一次结果挂在 globalThis 上,
1221
+ * 新 fiber 一装载就回灌,面板不再自己变空。绝不写文件(镜像是运行态,不该进用户的补丁)。
1222
+ */
1223
+ export const PANEL_STORE_KEY = '__DSHPUSH_PANEL__'
1224
+
1225
+ export function panelStore() {
1226
+ const g = globalThis
1227
+ if (!g[PANEL_STORE_KEY]) g[PANEL_STORE_KEY] = {}
1228
+ return g[PANEL_STORE_KEY]
1229
+ }
1230
+
1231
+ /** 每个 profile 只有一个「当前 fiber」:旧 fiber 跑完要把结果转交给新 fiber。 */
1232
+ const PANEL_ACTIVE = new Map()
1233
+
1234
+ /** Bridges the Web pass panel to the relay provisioning service. */
1235
+ export class PassProvisioner {
1236
+ constructor(ctx, config, resolved, log, deps = {}) {
1237
+ this.ctx = ctx
1238
+ this.config = config
1239
+ this.resolved = resolved
1240
+ this.log = log
1241
+ this.fetchImpl = deps.fetchImpl || globalThis.fetch
1242
+ this.writeCreds = deps.writeCredentialsPatch || writeCredentialsPatch
1243
+ this.credentialsPath = deps.credentialsPath || ''
1244
+ this.busy = false
1245
+ this.scheduled = false
1246
+ /** 已经处理过的请求号;同一个 seq 绝不重复发号(重复点按由页面换新 seq)。 */
1247
+ this.lastSeq = ''
1248
+ }
1249
+
1250
+ read(key) { return volatileValue(this.config[key], '') }
1251
+
1252
+ /** 暂存按 profile 分键(同一台机器可能跑多个 profile)。 */
1253
+ storeKey() {
1254
+ return String(this.resolved.host || '') + '|' + String(this.credentialsPath || '')
1255
+ }
1256
+
1257
+ /** 把最后一次结果回灌进当前 fiber 的 volatile 镜像(fiber 刚重启时面板不该是空的)。 */
1258
+ applyStore() {
1259
+ const saved = panelStore()[this.storeKey()]
1260
+ if (!saved) return
1261
+ this.write('provisionResult', String(saved.result || ''))
1262
+ this.write('provisionList', String(saved.list || ''))
1263
+ this.write('provisionDone', String(saved.done || ''))
1264
+ }
1265
+
1266
+ /** 存住本次结果;若已有更新的 fiber 接管(补丁写入引发的重启),转交给它回灌。 */
1267
+ saveStore() {
1268
+ const snapshot = {
1269
+ result: String(this.read('provisionResult') || ''),
1270
+ list: String(this.read('provisionList') || ''),
1271
+ done: String(this.read('provisionDone') || ''),
1272
+ }
1273
+ panelStore()[this.storeKey()] = snapshot
1274
+ const active = PANEL_ACTIVE.get(this.storeKey())
1275
+ if (active && active !== this) {
1276
+ active.config.provisionResult = undefined
1277
+ active.applyStore()
1278
+ this.log('通行证结果已转交给重启后的 fiber(补丁写入会热重启本插件)')
1279
+ }
1280
+ }
1281
+
1282
+ write(key, value) { setVolatileValue(this.config, key, value) }
1283
+
1284
+ /** 开机就把建议名字推给页面预填(用户可改)。 */
1285
+ publishDefault() {
1286
+ const suggested = provisionNameSuggest(this.resolved)
1287
+ if (this.read('provisionDefault') !== suggested) this.write('provisionDefault', suggested)
1288
+ }
1289
+
1290
+ /** volatile-update 发生在 settings RPC 的事务里 ⇒ 挪到下一个宏任务再跑(同 AskUserModeSwitch)。 */
1291
+ schedule() {
1292
+ if (this.scheduled) return
1293
+ this.scheduled = true
1294
+ setTimeout(() => {
1295
+ this.scheduled = false
1296
+ // Same rule as AskUserModeSwitch: never leave an unhandled rejection behind (the host
1297
+ // turns one into a fatal load failure and the whole process exits).
1298
+ Promise.resolve(this.run()).catch(() => {})
1299
+ }, 0)
1300
+ }
1301
+
1302
+ async list() {
1303
+ const out = await provisionCall({
1304
+ base: this.resolved.provisionBase, token: this.resolved.provisionToken,
1305
+ path: 'list', fetchImpl: this.fetchImpl,
1306
+ })
1307
+ if (!out.ok) return { ok: false, error: out.error }
1308
+ const passes = Array.isArray(out.data.passes) ? out.data.passes : []
1309
+ return { ok: true, passes }
1310
+ }
1311
+
1312
+ /** One panel round-trip: issue / revoke / refresh, then mirror everything back. */
1313
+ async run() {
1314
+ const seq = String(this.read('provisionSeq') || '')
1315
+ const action = String(this.read('provisionAction') || '')
1316
+ if (this.busy || seq === '' || seq === this.lastSeq) return
1317
+ if (action !== 'issue' && action !== 'revoke' && action !== 'refresh') return
1318
+ this.lastSeq = seq
1319
+ this.busy = true
1320
+ const name = String(this.read('provisionName') || '').trim() || provisionNameSuggest(this.resolved)
1321
+ try {
1322
+ if (action === 'issue') {
1323
+ const out = await provisionCall({
1324
+ base: this.resolved.provisionBase, token: this.resolved.provisionToken,
1325
+ path: 'issue', body: { name }, fetchImpl: this.fetchImpl,
1326
+ })
1327
+ if (!out.ok) {
1328
+ this.write('provisionResult', '发号失败:' + out.error)
1329
+ this.log('通行证发号失败:' + out.error)
1330
+ } else {
1331
+ const data = out.data || {}
1332
+ const text = passText({
1333
+ relayBase: this.resolved.relayBase, topic: data.topic, readToken: data.readToken,
1334
+ })
1335
+ this.write('provisionResult', text)
1336
+ this.log('通行证已签发:topic=' + data.topic + ',通行证文本已回写页面')
1337
+ if (this.credentialsPath) {
1338
+ try {
1339
+ // 别名一起写:loader 里 ntfyToken 排在 writetoken 之前(credentials.mjs:77),
1340
+ // 新值一定赢;但只写新键会让文件里留着旧 writeToken 骗读文件的人,所以两边同步。
1341
+ const saved = this.writeCreds(this.credentialsPath, {
1342
+ topic: data.topic,
1343
+ ntfyToken: data.writeToken, writeToken: data.writeToken,
1344
+ phoneToken: data.readToken, readToken: data.readToken,
1345
+ })
1346
+ this.log('已写回凭据文件 ' + saved.path + '(重启本插件后按新 topic 发布)')
1347
+ } catch (error) {
1348
+ this.log('凭据文件写入失败:' + String(error && error.message ? error.message : error))
1349
+ }
1350
+ }
1351
+ }
1352
+ } else if (action === 'revoke') {
1353
+ const out = await provisionCall({
1354
+ base: this.resolved.provisionBase, token: this.resolved.provisionToken,
1355
+ path: 'revoke', body: { name }, fetchImpl: this.fetchImpl,
1356
+ })
1357
+ this.write('provisionResult', out.ok ? ('已吊销:' + name) : ('吊销失败:' + out.error))
1358
+ this.log(out.ok ? ('通行证已吊销:' + name) : ('通行证吊销失败:' + out.error))
1359
+ }
1360
+ const listed = await this.list()
1361
+ // 载荷瘦身:面板只要 name 与 topic/permission,users 列表对渲染没用却会把镜像撑大。
1362
+ const slim = listed.ok
1363
+ ? listed.passes.slice(0, 40).map((row) => ({
1364
+ name: row.name,
1365
+ topics: (row.topics || []).map((t) => ({ topic: t.topic, permission: t.permission })),
1366
+ }))
1367
+ : []
1368
+ this.log('通行证列表:' + (listed.ok ? String(slim.length) + ' 条' : ('读取失败 ' + listed.error)))
1369
+ this.write('provisionList', listed.ok ? JSON.stringify(slim) : ('__err__' + listed.error))
1370
+ } catch (error) {
1371
+ const message = String(error && error.message ? error.message : error)
1372
+ this.write('provisionResult', '发号异常:' + message)
1373
+ this.log('通行证发号异常:' + message)
1374
+ } finally {
1375
+ this.busy = false
1376
+ this.write('provisionDone', seq)
1377
+ this.saveStore()
1378
+ }
1379
+ }
1380
+
1381
+ /** Wire the trigger: any volatile write under provision* (except our own mirrors). */
1382
+ start() {
1383
+ this.publishDefault()
1384
+ // 认领「当前 fiber」并把上一次的结果回灌:浏览器写 volatile 会改补丁文件 ⇒ 本插件 fiber
1385
+ // 会被热重启 ⇒ 不这么做的话面板结果只显示一瞬间就自己清空(2026-10-17 真机实测)。
1386
+ PANEL_ACTIVE.set(this.storeKey(), this)
1387
+ this.applyStore()
1388
+ const mine = ['provisionAction', 'provisionName', 'provisionSeq']
1389
+ this.ctx.on('loader/volatile-update', (paths) => {
1390
+ const touched = Array.isArray(paths) && paths.some((path) =>
1391
+ Array.isArray(path) && mine.includes(path[0]))
1392
+ if (touched) this.schedule()
1393
+ })
1394
+ }
1395
+ }
1396
+
1397
+ export function apply(ctx, config = {}) {
1398
+ const { resolved, file } = resolvePluginConfig(config)
1399
+ const host = new PushHost(ctx, resolved)
1400
+ reportCredentials(file, (message) => host.log(message))
1401
+ if (file.present && file.error === undefined) host.log('credentials file: ' + file.path)
1402
+ // Question notifications: claim the request waterfall, then hand the request back.
1403
+ ctx.on('user-questions/request', (request, next) => host.onRequest(request, next), { prepend: true })
1404
+ // Turn-end notification (the Stop-hook parity), the PreToolUse notify fallback and the
1405
+ // `#ans` echo (PostToolUse parity).
1406
+ ctx.on('agent/turn-stopping', (payload) => host.onTurnStopping(payload))
1407
+ ctx.on('tools/pre-execute', (exec, next) => host.onPreExecute(exec, next))
1408
+ ctx.on('tools/post-execute', (exec, result, next) => host.onPostExecute(exec, result, next))
1409
+ ctx.effect(() => () => host.stop(), 'dsh-push-host: relay bookkeeping')
1410
+ host.start()
1411
+ // 手机作答模式开关(v0.2.1):显式 timed → 延后出 HMR 事务 → 备份 → configEditor 热改
1412
+ // tool-ask-user;未表态/legacy 完全惰性,永不降级,每进程最多成功写一次。
1413
+ new AskUserModeSwitch(ctx, config, (message) => host.log(message)).start()
1414
+ // 通行证发号面板(v0.3.0):网页写 volatile 的 provisionAction/Name/Seq → 这里调中继的
1415
+ // dsh-provd → 把通行证文本与列表镜像回 volatile 字段;发号成功顺手把 topic + 两只 token
1416
+ // 写回插件自己的凭据文件(先备份)。重启本插件 fiber 后按新 topic 发布。
1417
+ new PassProvisioner(ctx, config, resolved, (message) => host.log(message), {
1418
+ credentialsPath: file.path,
1419
+ }).start()
1420
+ }
1421
+
1422
+ export { PushHost, resolveConfig, CONFIG_KEYS }