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/LICENSE +21 -0
- package/README.md +667 -2
- package/cordis.patch.yml +7 -0
- package/lib/client.js +470 -0
- package/lib/credentials.mjs +215 -0
- package/lib/index.js +1422 -0
- package/lib/pairing-lib.mjs +69 -0
- package/lib/pairing.mjs +244 -0
- package/lib/relay.js +100 -0
- package/package.json +76 -4
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 }
|