dsh-plugin-mobile-link 0.4.1 → 0.6.0
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/README.md +2 -1
- package/cordis.patch.yml +23 -0
- package/lib/cli.js +6 -6
- package/lib/client.js +493 -0
- package/lib/hello.js +15 -2
- package/lib/history/probe.js +195 -0
- package/lib/history/records.js +97 -0
- package/lib/history/shared.js +25 -0
- package/lib/history/window.js +90 -0
- package/lib/history.js +355 -0
- package/lib/index.js +29 -6
- package/lib/link.js +88 -0
- package/lib/qr.js +34 -5
- package/lib/router.js +22 -0
- package/lib/routes.js +58 -55
- package/package.json +5 -1
package/lib/history.js
ADDED
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
import { HOST_CALL_TIMEOUT_MS, withTimeout, messageOf } from './history/shared.js'
|
|
2
|
+
import { createProbeRunner } from './history/probe.js'
|
|
3
|
+
import {
|
|
4
|
+
DEFAULT_LIMIT, DEFAULT_MAX_BYTES, WINDOW_CHUNK,
|
|
5
|
+
bytesOfRecords, displayRoleOf, roleOf, toRecord,
|
|
6
|
+
} from './history/records.js'
|
|
7
|
+
import { pageFrom, scanOlder } from './history/window.js'
|
|
8
|
+
|
|
9
|
+
// Re-exported for the caller/tests that read them off this module (single source
|
|
10
|
+
// is `history/records.js`; the call ceiling lives in `history/shared.js`).
|
|
11
|
+
export { DEFAULT_MAX_BYTES, DEFAULT_LIMIT, WINDOW_CHUNK } from './history/records.js'
|
|
12
|
+
export { HOST_CALL_TIMEOUT_MS } from './history/shared.js'
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* History window reads and in-session search, answered **locally** by the connector.
|
|
16
|
+
*
|
|
17
|
+
* Why local: the Host already owns a correct, seq-exact reader
|
|
18
|
+
* (`ctx.sessionQuery`, see @deepseek-ai/dsh-session-query) but does **not** expose
|
|
19
|
+
* it over the remote API the phone talks to. The only history method on that
|
|
20
|
+
* surface is `session/page`, whose `beforeSeq` lands in log-offset space, so a
|
|
21
|
+
* session containing offset-only events silently skips a stretch of history —
|
|
22
|
+
* measured at 235 seqs on a real session (see DSHKit's TranscriptPageBoundary).
|
|
23
|
+
*
|
|
24
|
+
* So the connector reads the Host's own state through the injected service and
|
|
25
|
+
* answers two extra methods:
|
|
26
|
+
*
|
|
27
|
+
* _link/sessionWindow exact window ending before a given seq
|
|
28
|
+
* _link/sessionFind literal text search within one session
|
|
29
|
+
*
|
|
30
|
+
* Feature detection is the switch, not version comparison: a connector that
|
|
31
|
+
* lacks the injected service answers `link/unsupported`, and the phone keeps
|
|
32
|
+
* using `session/page`. Nothing about the existing methods changes.
|
|
33
|
+
*
|
|
34
|
+
* SPIKE NOTE (spec 007, task T008): `probe()` below is a temporary read-only
|
|
35
|
+
* measurement used to pick the exact Host API mapping. It is wired to
|
|
36
|
+
* `GET /mobile-link/history-probe` and is removed once the mapping is locked.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
export const WINDOW_METHOD = '_link/sessionWindow'
|
|
40
|
+
export const FIND_METHOD = '_link/sessionFind'
|
|
41
|
+
|
|
42
|
+
/// Declared in `_link/hello`; the phone switches channels on these.
|
|
43
|
+
/// `history-role-filter`: `_link/sessionWindow` accepts an optional `roles`
|
|
44
|
+
/// array (e.g. `["me"]`) and returns only matching records; the seq cursor
|
|
45
|
+
/// semantics are unchanged (spec 009 T031).
|
|
46
|
+
/// `history-scan-boundary`: a `roles`-filtered page also carries `scanBoundary`,
|
|
47
|
+
/// the seq the scan really reached, so a caller can advance its cursor when a
|
|
48
|
+
/// whole page is filtered empty (spec 009 T039). Purely additive: a client that
|
|
49
|
+
/// does not read the field keeps the old cursor rule.
|
|
50
|
+
export const HISTORY_CAPABILITIES = [
|
|
51
|
+
'history-window', 'session-search', 'history-role-filter', 'history-scan-boundary',
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
export function isHistoryMethod(method) {
|
|
55
|
+
return method === WINDOW_METHOD || method === FIND_METHOD
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/// One search chunk, and how many of them one call may scan. Measured on a
|
|
59
|
+
/// 12968-event session: ~320ms per 2000-event chunk, so four chunks keep a call
|
|
60
|
+
/// near 1.3s — well inside the 8s host-call budget and short enough not to hold
|
|
61
|
+
/// the connector's single inbound chain for long (spec 007 FR-019).
|
|
62
|
+
export const SEARCH_CHUNK = 2000
|
|
63
|
+
export const MAX_SEARCH_CHUNKS_PER_CALL = 4
|
|
64
|
+
export const DEFAULT_SEARCH_LIMIT = 50
|
|
65
|
+
export const MIN_QUERY_LENGTH = 2
|
|
66
|
+
|
|
67
|
+
function idOfAddress(address) {
|
|
68
|
+
if (address === null || typeof address !== 'object') return undefined
|
|
69
|
+
if (address.kind === 'session') return typeof address.sessionId === 'string' ? address.sessionId : undefined
|
|
70
|
+
if (address.kind === 'subagent') {
|
|
71
|
+
return typeof address.childSessionId === 'string' ? address.childSessionId : undefined
|
|
72
|
+
}
|
|
73
|
+
return undefined
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/// Validate the optional `roles` argument: unknown values would silently match
|
|
77
|
+
/// nothing, so the caller gets a bad-request instead of an empty page.
|
|
78
|
+
const KNOWN_ROLES = new Set(['me', 'agent', 'tool'])
|
|
79
|
+
|
|
80
|
+
function parseRoles(value) {
|
|
81
|
+
if (value === undefined) return { roles: undefined }
|
|
82
|
+
if (!Array.isArray(value) || value.length === 0) return { roles: undefined }
|
|
83
|
+
const roles = new Set(value)
|
|
84
|
+
for (const role of roles) {
|
|
85
|
+
if (!KNOWN_ROLES.has(role)) {
|
|
86
|
+
return { error: failure('gateway/bad-request', `unknown role "${String(role)}"`) }
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return { roles }
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/// A hit snippet with the highlight offsets the phone's list already renders.
|
|
93
|
+
///
|
|
94
|
+
/// Offsets, not ranges: the value crosses the wire and a character offset stays
|
|
95
|
+
/// meaningful on the other side.
|
|
96
|
+
function makeSnippet(text, query, radius = 36) {
|
|
97
|
+
const haystack = String(text ?? '')
|
|
98
|
+
const at = haystack.toLowerCase().indexOf(query.toLowerCase())
|
|
99
|
+
if (at < 0) {
|
|
100
|
+
const head = haystack.slice(0, radius * 2).replace(/\s+/g, ' ').trim()
|
|
101
|
+
return { snippet: head, highlightStart: 0, highlightLength: 0 }
|
|
102
|
+
}
|
|
103
|
+
const start = Math.max(0, at - radius)
|
|
104
|
+
const end = Math.min(haystack.length, at + query.length + radius)
|
|
105
|
+
const snippet = haystack.slice(start, end).replace(/\s+/g, ' ').trim()
|
|
106
|
+
// Re-find the match inside the flattened snippet so the offsets line up with
|
|
107
|
+
// what is actually displayed.
|
|
108
|
+
const flatAt = snippet.toLowerCase().indexOf(query.toLowerCase())
|
|
109
|
+
return {
|
|
110
|
+
snippet,
|
|
111
|
+
highlightStart: flatAt < 0 ? 0 : flatAt,
|
|
112
|
+
highlightLength: flatAt < 0 ? 0 : query.length,
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function failure(code, message, details = {}) {
|
|
117
|
+
return { ok: false, error: { code, message, details } }
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/// Map a thrown Host/gateway error onto the contract's codes.
|
|
121
|
+
function hostFailure(error) {
|
|
122
|
+
const message = messageOf(error)
|
|
123
|
+
if (/timed out after/.test(message)) return failure('link/timeout', message)
|
|
124
|
+
if (/not found|no such session/i.test(message)) return failure('session/unknown', message)
|
|
125
|
+
return failure('gateway/failed', message)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* @param {{ logger?: { warn?: Function, info?: Function } }} deps
|
|
130
|
+
*/
|
|
131
|
+
export function createHistoryService({ logger = console } = {}) {
|
|
132
|
+
let sessionQuery
|
|
133
|
+
const probeRunner = createProbeRunner({ getQuery: () => sessionQuery, logger })
|
|
134
|
+
/// Set to false after the Host has told us its full-text index is closed
|
|
135
|
+
/// (`openAt: "never"`), so later searches go straight to the scan.
|
|
136
|
+
let indexSearchable = true
|
|
137
|
+
const inFlight = new Set()
|
|
138
|
+
const lastSeqCache = new Map()
|
|
139
|
+
|
|
140
|
+
/// The log's newest seq, cached briefly: it is only needed to clamp a request
|
|
141
|
+
/// that runs past the end (a session can be trimmed between two pages).
|
|
142
|
+
async function newestSeq(sessionId) {
|
|
143
|
+
const cached = lastSeqCache.get(sessionId)
|
|
144
|
+
if (cached !== undefined && Date.now() - cached.at < 5000) return cached.seq
|
|
145
|
+
const records = await withTimeout(sessionQuery.listEvents(sessionId), HOST_CALL_TIMEOUT_MS, 'listEvents')
|
|
146
|
+
const seq = records.length > 0 ? records[records.length - 1].seq : -1
|
|
147
|
+
lastSeqCache.set(sessionId, { at: Date.now(), seq })
|
|
148
|
+
return seq
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/// One exact backward window ending at `seq` (inclusive).
|
|
152
|
+
///
|
|
153
|
+
/// `clamped` is reported upward: when the session was trimmed, the page cannot
|
|
154
|
+
/// abut the phone's window, and the phone needs to know that the gap is the
|
|
155
|
+
/// log's doing rather than a hole it should treat as a bug (FR-002/FR-041).
|
|
156
|
+
async function readChunk(sessionId, seq) {
|
|
157
|
+
try {
|
|
158
|
+
const window = await withTimeout(
|
|
159
|
+
sessionQuery.readEvent({ sessionId, seq, before: WINDOW_CHUNK, after: 0 }),
|
|
160
|
+
HOST_CALL_TIMEOUT_MS,
|
|
161
|
+
'readEvent',
|
|
162
|
+
)
|
|
163
|
+
return { window, clamped: false }
|
|
164
|
+
} catch (error) {
|
|
165
|
+
// Past the end of a trimmed log: clamp once and retry, never guess.
|
|
166
|
+
if (!/has no event at seq/.test(messageOf(error))) throw error
|
|
167
|
+
const newest = await newestSeq(sessionId)
|
|
168
|
+
if (newest < 0 || newest >= seq) throw error
|
|
169
|
+
lastSeqCache.delete(sessionId)
|
|
170
|
+
const window = await withTimeout(
|
|
171
|
+
sessionQuery.readEvent({ sessionId, seq: newest, before: WINDOW_CHUNK, after: 0 }),
|
|
172
|
+
HOST_CALL_TIMEOUT_MS,
|
|
173
|
+
'readEvent(clamped)',
|
|
174
|
+
)
|
|
175
|
+
return { window, clamped: true }
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/// One page of in-session literal search (spec 007 FR-010…FR-012).
|
|
180
|
+
///
|
|
181
|
+
/// Preferred path is the Host's indexed `searchEvents`; this deployment ships it
|
|
182
|
+
/// disabled (`openAt: "never"`), so the working path is a bounded scan: chunks of
|
|
183
|
+
/// `SEARCH_CHUNK` seqs, filtered by seq range **and** the literal text, newest
|
|
184
|
+
/// first. Each call scans at most `MAX_SEARCH_CHUNKS_PER_CALL` chunks and hands
|
|
185
|
+
/// back a cursor, so "keep looking older" is the reader's next request rather
|
|
186
|
+
/// than one call that holds the tunnel.
|
|
187
|
+
async function find(args) {
|
|
188
|
+
const sessionId = idOfAddress(args.address)
|
|
189
|
+
if (sessionId === undefined) return failure('gateway/bad-request', 'address must name a session')
|
|
190
|
+
const query = typeof args.query === 'string' ? args.query.trim() : ''
|
|
191
|
+
const roles = Array.isArray(args.roles) && args.roles.length > 0 ? new Set(args.roles) : undefined
|
|
192
|
+
// 浏览模式:**没有关键词但带了 roles**(读者要看"我问过的全部提问")。
|
|
193
|
+
// 这时不按文本过滤,只按 seq 段扫,按角色筛。
|
|
194
|
+
const browsing = query.length === 0 && roles !== undefined
|
|
195
|
+
if (!browsing && query.length < MIN_QUERY_LENGTH) {
|
|
196
|
+
return failure('link/query-too-short', `query must be at least ${MIN_QUERY_LENGTH} characters`)
|
|
197
|
+
}
|
|
198
|
+
const limit = Number.isSafeInteger(Number(args.limit)) && Number(args.limit) > 0
|
|
199
|
+
? Math.min(Number(args.limit), 200) : DEFAULT_SEARCH_LIMIT
|
|
200
|
+
// Advanced knob (also used by the measurement route and the tests): a smaller
|
|
201
|
+
// chunk makes the cursor path reachable on short sessions. Clamped, never 0.
|
|
202
|
+
const chunk = Number.isSafeInteger(Number(args.chunk)) && Number(args.chunk) > 0
|
|
203
|
+
? Math.min(Number(args.chunk), SEARCH_CHUNK) : SEARCH_CHUNK
|
|
204
|
+
const key = `find:${sessionId}`
|
|
205
|
+
if (inFlight.has(key)) return failure('link/busy', 'another search is in flight for this session')
|
|
206
|
+
inFlight.add(key)
|
|
207
|
+
try {
|
|
208
|
+
// Fast path when the deployment has the index open. One attempt, and a
|
|
209
|
+
// refusal is remembered: the scan below is the path that always works.
|
|
210
|
+
if (indexSearchable && args.cursor === undefined && typeof sessionQuery.searchEvents === 'function') {
|
|
211
|
+
try {
|
|
212
|
+
const page = await withTimeout(
|
|
213
|
+
sessionQuery.searchEvents({ sessionId, query, limit }),
|
|
214
|
+
HOST_CALL_TIMEOUT_MS,
|
|
215
|
+
'searchEvents',
|
|
216
|
+
)
|
|
217
|
+
const indexed = (page.items ?? [])
|
|
218
|
+
.map((item) => {
|
|
219
|
+
const role = roleOf(item)
|
|
220
|
+
if (roles !== undefined && (role === undefined || !roles.has(role))) return undefined
|
|
221
|
+
const display = displayRoleOf(item)
|
|
222
|
+
if (display === undefined) return undefined
|
|
223
|
+
return { seq: item.seq, role: display, ...makeSnippet(item.snippet ?? item.text ?? '', query) }
|
|
224
|
+
})
|
|
225
|
+
.filter(Boolean)
|
|
226
|
+
return {
|
|
227
|
+
ok: true,
|
|
228
|
+
value: {
|
|
229
|
+
hits: indexed,
|
|
230
|
+
cursor: page.hasMore === true && page.cursor !== undefined ? String(page.cursor) : null,
|
|
231
|
+
hasMore: page.hasMore === true,
|
|
232
|
+
source: 'index',
|
|
233
|
+
},
|
|
234
|
+
}
|
|
235
|
+
} catch (error) {
|
|
236
|
+
indexSearchable = false
|
|
237
|
+
logger.info?.(`mobile-link: full-text index unavailable, scanning instead: ${messageOf(error)}`)
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
const from = parseSearchCursor(args.cursor)
|
|
241
|
+
const ceiling = from !== undefined ? from : await newestSeq(sessionId)
|
|
242
|
+
const hits = []
|
|
243
|
+
let to = ceiling
|
|
244
|
+
let scanned = 0
|
|
245
|
+
while (to >= 0 && hits.length < limit && scanned < MAX_SEARCH_CHUNKS_PER_CALL) {
|
|
246
|
+
const chunkFrom = Math.max(0, to - chunk + 1)
|
|
247
|
+
const filters = [{ kind: 'seq', from: chunkFrom, to }]
|
|
248
|
+
if (!browsing) filters.push({ kind: 'text', text: query })
|
|
249
|
+
const docs = await withTimeout(
|
|
250
|
+
sessionQuery.filterEvents(sessionId, filters),
|
|
251
|
+
HOST_CALL_TIMEOUT_MS,
|
|
252
|
+
'filterEvents(search)',
|
|
253
|
+
)
|
|
254
|
+
scanned += 1
|
|
255
|
+
// Newest first inside the chunk: the reader asked about something recent
|
|
256
|
+
// far more often than about something old.
|
|
257
|
+
for (let index = docs.length - 1; index >= 0; index -= 1) {
|
|
258
|
+
const doc = docs[index]
|
|
259
|
+
const role = roleOf(doc)
|
|
260
|
+
if (roles !== undefined && (role === undefined || !roles.has(role))) continue
|
|
261
|
+
const display = displayRoleOf(doc)
|
|
262
|
+
if (display === undefined) continue
|
|
263
|
+
hits.push({ seq: doc.seq, role: display, ...(query.length > 0
|
|
264
|
+
? makeSnippet(doc.text, query)
|
|
265
|
+
: { snippet: String(doc.text ?? '').slice(0, 72).replace(/\s+/g, ' ').trim(),
|
|
266
|
+
highlightStart: 0, highlightLength: 0 }) })
|
|
267
|
+
if (hits.length >= limit) break
|
|
268
|
+
}
|
|
269
|
+
to = chunkFrom - 1
|
|
270
|
+
}
|
|
271
|
+
const hasMore = to >= 0 && hits.length >= limit ? true : to >= 0
|
|
272
|
+
return {
|
|
273
|
+
ok: true,
|
|
274
|
+
value: {
|
|
275
|
+
hits,
|
|
276
|
+
cursor: hasMore ? String(to) : null,
|
|
277
|
+
hasMore,
|
|
278
|
+
scannedChunks: scanned,
|
|
279
|
+
source: 'scan',
|
|
280
|
+
},
|
|
281
|
+
}
|
|
282
|
+
} catch (error) {
|
|
283
|
+
logger.warn?.(`mobile-link: sessionFind failed: ${messageOf(error)}`)
|
|
284
|
+
return hostFailure(error)
|
|
285
|
+
} finally {
|
|
286
|
+
inFlight.delete(key)
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/// The cursor is the next `to` seq — opaque to the phone, validated here.
|
|
291
|
+
function parseSearchCursor(cursor) {
|
|
292
|
+
if (typeof cursor !== 'string' || cursor.length === 0) return undefined
|
|
293
|
+
const value = Number(cursor)
|
|
294
|
+
return Number.isSafeInteger(value) && value >= 0 ? value : undefined
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
async function window(args) {
|
|
298
|
+
const sessionId = idOfAddress(args.address)
|
|
299
|
+
const before = Number(args.before)
|
|
300
|
+
if (sessionId === undefined) return failure('gateway/bad-request', 'address must name a session')
|
|
301
|
+
if (!Number.isSafeInteger(before) || before < 0) return failure('gateway/bad-request', 'before must be a non-negative integer')
|
|
302
|
+
const limit = Number.isSafeInteger(Number(args.limit)) && Number(args.limit) > 0 ? Number(args.limit) : DEFAULT_LIMIT
|
|
303
|
+
const maxBytes = Number.isSafeInteger(Number(args.maxBytes)) && Number(args.maxBytes) > 0
|
|
304
|
+
? Number(args.maxBytes) : DEFAULT_MAX_BYTES
|
|
305
|
+
const through = Number.isSafeInteger(Number(args.through)) ? Number(args.through) : undefined
|
|
306
|
+
// Optional role filter (spec 009 T031): e.g. `roles: ["me"]` returns only
|
|
307
|
+
// the caller's own questions. Filtering affects **which records come back,
|
|
308
|
+
// never the cursor**: the window is still read as contiguous seqs and
|
|
309
|
+
// `before` still bounds the newest edge exactly. Since T039 a filtered page
|
|
310
|
+
// also carries `scanBoundary` (see history/window.js) so a caller can keep
|
|
311
|
+
// moving when a whole page is filtered empty.
|
|
312
|
+
const { roles, error: rolesError } = parseRoles(args.roles)
|
|
313
|
+
if (rolesError) return rolesError
|
|
314
|
+
|
|
315
|
+
const key = `window:${sessionId}`
|
|
316
|
+
if (inFlight.has(key)) return failure('link/busy', 'another window read is in flight for this session')
|
|
317
|
+
inFlight.add(key)
|
|
318
|
+
try {
|
|
319
|
+
const scan = await scanOlder({ readChunk, sessionId, before, through, limit, maxBytes, roles })
|
|
320
|
+
return { ok: true, value: pageFrom(scan, { limit, maxBytes, roles }) }
|
|
321
|
+
} catch (error) {
|
|
322
|
+
logger.warn?.(`mobile-link: sessionWindow failed: ${messageOf(error)}`)
|
|
323
|
+
return hostFailure(error)
|
|
324
|
+
} finally {
|
|
325
|
+
inFlight.delete(key)
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
return {
|
|
330
|
+
/// Called from an optional injection; absent on Hosts that predate the service.
|
|
331
|
+
bindQuery(service) {
|
|
332
|
+
sessionQuery = service
|
|
333
|
+
logger.info?.('mobile-link: history service bound to ctx.sessionQuery')
|
|
334
|
+
},
|
|
335
|
+
get available() {
|
|
336
|
+
return sessionQuery !== undefined
|
|
337
|
+
},
|
|
338
|
+
|
|
339
|
+
/// Frame handler for the two methods.
|
|
340
|
+
async handle(method, args = {}) {
|
|
341
|
+
if (sessionQuery === undefined) {
|
|
342
|
+
return failure('link/unsupported', 'this Host does not expose the session query service')
|
|
343
|
+
}
|
|
344
|
+
if (method === WINDOW_METHOD) return window(args)
|
|
345
|
+
if (method === FIND_METHOD) return find(args)
|
|
346
|
+
return failure('link/unknown-method', String(method))
|
|
347
|
+
},
|
|
348
|
+
|
|
349
|
+
/// Exposed for the unit tests and the (gated) measurement route.
|
|
350
|
+
/// 只读测量(spec 007 T008/T047):实现见 `history/probe.js`。
|
|
351
|
+
async probe(args = {}) {
|
|
352
|
+
return probeRunner(args)
|
|
353
|
+
},
|
|
354
|
+
}
|
|
355
|
+
}
|
package/lib/index.js
CHANGED
|
@@ -7,12 +7,15 @@
|
|
|
7
7
|
* never been registered, redeem the invite code from the environment (or
|
|
8
8
|
* the config) first, so a fresh install becomes usable without the relay
|
|
9
9
|
* operator provisioning anything by hand.
|
|
10
|
-
* 2.
|
|
10
|
+
* 2. Two routes under `/mobile-link` behind DSH's own connection fence:
|
|
11
11
|
* GET /status — is the link up, which devices are attached, and is
|
|
12
12
|
* this computer registered yet
|
|
13
|
-
* POST /pair-code — mint a pairing code
|
|
14
|
-
*
|
|
13
|
+
* POST /pair-code — mint a pairing code through the relay, with the QR
|
|
14
|
+
* payload and the symbol rendered alongside it
|
|
15
15
|
*
|
|
16
|
+
* The pairing screen itself lives in the client half (`lib/client.js`, spec 010):
|
|
17
|
+
* a standing button in the sidebar plus a panel that shows the code, its QR and
|
|
18
|
+
* the paired phones. There is no browser page for pairing any more.
|
|
16
19
|
* Deliberate design choices:
|
|
17
20
|
* * The plugin declares **no required services** (`inject` is empty). The DLP
|
|
18
21
|
* agent must run even if a future DSH renames or drops `webServer` /
|
|
@@ -28,6 +31,7 @@
|
|
|
28
31
|
|
|
29
32
|
import { MobileLinkAgent } from './link.js'
|
|
30
33
|
import { registerMobileLinkRoutes } from './routes.js'
|
|
34
|
+
import { createHistoryService } from './history.js'
|
|
31
35
|
|
|
32
36
|
/** Cordis function-plugin name (the loader row id is separate). */
|
|
33
37
|
export const name = 'mobile-link'
|
|
@@ -116,10 +120,10 @@ function startAgent(ctx, agent, logger) {
|
|
|
116
120
|
}
|
|
117
121
|
|
|
118
122
|
/** 三条本机 HTTP 路由;只依赖 `webServer`,缺了也不影响链路。 */
|
|
119
|
-
function registerRoutes(ctx, agent, logger, config) {
|
|
123
|
+
function registerRoutes(ctx, agent, logger, config, history) {
|
|
120
124
|
try {
|
|
121
125
|
ctx.inject(['webServer'], (scope) => {
|
|
122
|
-
const disposers = registerMobileLinkRoutes(scope, { agent, logger, config })
|
|
126
|
+
const disposers = registerMobileLinkRoutes(scope, { agent, logger, config, history })
|
|
123
127
|
scope.effect?.(() => () => {
|
|
124
128
|
for (const dispose of disposers) {
|
|
125
129
|
try {
|
|
@@ -154,6 +158,22 @@ function wireEndpointResolver(ctx, agent, logger) {
|
|
|
154
158
|
}
|
|
155
159
|
}
|
|
156
160
|
|
|
161
|
+
/**
|
|
162
|
+
* 把宿主的会话查询服务交给历史能力(**可选注入**)。
|
|
163
|
+
*
|
|
164
|
+
* 拿不到就保持"不支持":宿主里没有这个服务(旧版本)时,历史能力回 `link/unsupported`,
|
|
165
|
+
* 手机端继续走 `session/page`。这条注入刻意独立于链路本身——注入失败不影响连接器运行。
|
|
166
|
+
*/
|
|
167
|
+
function wireSessionQuery(ctx, history, logger) {
|
|
168
|
+
try {
|
|
169
|
+
ctx.inject(['sessionQuery'], (scope) => {
|
|
170
|
+
history.bindQuery(scope.sessionQuery)
|
|
171
|
+
})
|
|
172
|
+
} catch (error) {
|
|
173
|
+
logger.warn?.(`mobile-link: session query unavailable (history falls back): ${error}`)
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
157
177
|
/**
|
|
158
178
|
* @param {import('@deepseek-ai/cordis').Context} ctx
|
|
159
179
|
* @param {Record<string, unknown>} rawConfig
|
|
@@ -162,10 +182,13 @@ export function apply(ctx, rawConfig) {
|
|
|
162
182
|
const config = { ...DEFAULTS, ...asRecord(rawConfig) }
|
|
163
183
|
const logger = ctx.logger ?? console
|
|
164
184
|
const agent = buildAgent(config, logger)
|
|
185
|
+
const history = createHistoryService({ logger })
|
|
186
|
+
agent.setHistoryService?.(history)
|
|
165
187
|
|
|
166
188
|
startAgent(ctx, agent, logger)
|
|
167
|
-
registerRoutes(ctx, agent, logger, config)
|
|
189
|
+
registerRoutes(ctx, agent, logger, config, history)
|
|
168
190
|
wireEndpointResolver(ctx, agent, logger)
|
|
191
|
+
wireSessionQuery(ctx, history, logger)
|
|
169
192
|
logger.info?.(`mobile-link: ready (relay ${config.relayUrl})`)
|
|
170
193
|
}
|
|
171
194
|
|
package/lib/link.js
CHANGED
|
@@ -8,6 +8,16 @@ import { EventEmitter } from 'node:events'
|
|
|
8
8
|
|
|
9
9
|
import { DshClient } from './dsh-client.js'
|
|
10
10
|
import { agentEndpoint, decodeFrame, encodeFrame, nextBackoff, normalizeRelayUrl, pongFor } from './dlp.js'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* 进程级登记表:**同一个进程里只允许有一条到中转的 agent 连接**。
|
|
14
|
+
*
|
|
15
|
+
* 为什么需要它:插件热更会重新求值模块、再建一个 Link,而旧的实例还占着那条连接
|
|
16
|
+
* (中转只认一个 agent 会话)。新实例于是连不上、手机那侧"连上了却没有链路"。
|
|
17
|
+
* 2026-10-09 00:00–00:42 真机连不上就是这个:连接器 43 分钟没回来。
|
|
18
|
+
* 挂在 `globalThis` 上是因为模块重载会把模块作用域清掉,只有进程级才留得住。
|
|
19
|
+
*/
|
|
20
|
+
const LIVE_AGENT = Symbol.for('dsh.mobile-link.live-agent')
|
|
11
21
|
import { enrollAgent, enrollCommand, inviteFrom } from './enroll.js'
|
|
12
22
|
import { handoffFromEndpoint, writeHandoff } from './handoff.js'
|
|
13
23
|
import { statusSnapshot } from './status.js'
|
|
@@ -46,6 +56,7 @@ export class MobileLinkAgent extends EventEmitter {
|
|
|
46
56
|
relayUrl, agentId, agentSecret, agentName, stateFile, dshUrl,
|
|
47
57
|
logger = console, heartbeatMs = 20000, pongTimeoutMs = 60000, maxBackoffMs = 30000,
|
|
48
58
|
random = Math.random, now = Date.now, dshClient, connectImpl = wsConnect, enabled = true,
|
|
59
|
+
watchdogMs = 180000,
|
|
49
60
|
inviteCode, fetchImpl = fetch, env = process.env,
|
|
50
61
|
// 手机不在时替它攒下来的提问条数上限(见 router.js);环境变量给部署用,
|
|
51
62
|
// 构造参数给测试用。**没有"留多久"这个旋钮**:`$events` 一直留到设备被撤销。
|
|
@@ -57,6 +68,7 @@ export class MobileLinkAgent extends EventEmitter {
|
|
|
57
68
|
this.heartbeatMs = heartbeatMs
|
|
58
69
|
this.pongTimeoutMs = pongTimeoutMs
|
|
59
70
|
this.maxBackoffMs = maxBackoffMs
|
|
71
|
+
this.watchdogMs = watchdogMs
|
|
60
72
|
this.random = random
|
|
61
73
|
this.now = now
|
|
62
74
|
this.connectImpl = connectImpl
|
|
@@ -85,6 +97,9 @@ export class MobileLinkAgent extends EventEmitter {
|
|
|
85
97
|
this.identity = undefined
|
|
86
98
|
/** Which host form we are running in (`desktop` / `web` / `unknown`); set by the plugin. */
|
|
87
99
|
this.hostForm = 'unknown'
|
|
100
|
+
/// 历史读取(spec 007):宿主 `sessionQuery` 就绪后由插件挂上;没挂上时
|
|
101
|
+
/// 路由回 `link/unsupported`,手机继续走 `session/page`。
|
|
102
|
+
this.setHistoryService = (service) => { this.router.history = service }
|
|
88
103
|
this.relay = undefined
|
|
89
104
|
this.socket = undefined
|
|
90
105
|
this.state = 'idle'
|
|
@@ -100,6 +115,9 @@ export class MobileLinkAgent extends EventEmitter {
|
|
|
100
115
|
this._inbox = Promise.resolve()
|
|
101
116
|
this._lastPong = 0
|
|
102
117
|
this._loop = undefined
|
|
118
|
+
this._loopActive = false
|
|
119
|
+
this._watchdog = undefined
|
|
120
|
+
this._lastProgressAt = 0
|
|
103
121
|
this._abort = new AbortController()
|
|
104
122
|
|
|
105
123
|
// The mux is shared by every device, so its events are wired once here.
|
|
@@ -199,6 +217,17 @@ export class MobileLinkAgent extends EventEmitter {
|
|
|
199
217
|
this.emit('status')
|
|
200
218
|
return this
|
|
201
219
|
}
|
|
220
|
+
// 先把上一个实例停掉(热更留下的那条连接):不停的话两个实例会抢同一个 agent 会话,
|
|
221
|
+
// 表现就是"手机连上中转、却等不到任何回复"。
|
|
222
|
+
const previous = globalThis[LIVE_AGENT]
|
|
223
|
+
if (previous && previous !== this) {
|
|
224
|
+
try {
|
|
225
|
+
await previous.stop()
|
|
226
|
+
} catch {
|
|
227
|
+
/* 旧实例停不掉也不能挡住新的 */
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
globalThis[LIVE_AGENT] = this
|
|
202
231
|
if (this._loop) return this
|
|
203
232
|
this._stopping = false
|
|
204
233
|
this._abort = new AbortController()
|
|
@@ -206,15 +235,22 @@ export class MobileLinkAgent extends EventEmitter {
|
|
|
206
235
|
this.state = 'connecting'
|
|
207
236
|
// 解析器可能在上一次会话里就已经装好;这里补一次本机发现(失败无妨)。
|
|
208
237
|
void this.refreshLocal()
|
|
238
|
+
this._lastProgressAt = this.now()
|
|
239
|
+
this.#startWatchdog()
|
|
240
|
+
this._loopActive = true
|
|
209
241
|
this._loop = this.#run().catch((error) => {
|
|
210
242
|
this.lastError = messageOf(error)
|
|
211
243
|
this.state = 'failed'
|
|
212
244
|
this.emit('status')
|
|
245
|
+
}).finally(() => {
|
|
246
|
+
this._loopActive = false
|
|
213
247
|
})
|
|
214
248
|
return this
|
|
215
249
|
}
|
|
216
250
|
|
|
217
251
|
async stop() {
|
|
252
|
+
this.#stopWatchdog()
|
|
253
|
+
if (globalThis[LIVE_AGENT] === this) globalThis[LIVE_AGENT] = undefined
|
|
218
254
|
this._stopping = true
|
|
219
255
|
this._abort.abort()
|
|
220
256
|
this.#teardownSocket('agent stopped')
|
|
@@ -324,6 +360,7 @@ export class MobileLinkAgent extends EventEmitter {
|
|
|
324
360
|
this.socket = socket
|
|
325
361
|
this.state = 'connected'
|
|
326
362
|
this.connectedAt = this.now()
|
|
363
|
+
this._lastProgressAt = this.now()
|
|
327
364
|
this.lastError = undefined
|
|
328
365
|
this.router.lastError = undefined
|
|
329
366
|
this._lastPong = this.now()
|
|
@@ -362,6 +399,54 @@ export class MobileLinkAgent extends EventEmitter {
|
|
|
362
399
|
})
|
|
363
400
|
}
|
|
364
401
|
|
|
402
|
+
/**
|
|
403
|
+
* 看门狗:**与单次会话的心跳无关**的兜底。
|
|
404
|
+
*
|
|
405
|
+
* 心跳只在"连上之后"跑,而且会话卡死时它依赖 `setInterval` 还在(热更把它换掉就没了)。
|
|
406
|
+
* 看门狗每 `heartbeatMs` 看一次"链路最近有没有进展":
|
|
407
|
+
* * 已连上但超过 `pongTimeoutMs` 没动静 ⇒ 强制拆掉这条 socket(会话随即返回、进重连);
|
|
408
|
+
* * **循环已经不在了**(`_loopActive === false`,典型是热更换掉了实例)⇒ 重新拉起循环。
|
|
409
|
+
* 正常重连退避期间(循环还在、状态是 connecting/backoff)它什么都不做,只在
|
|
410
|
+
* 超过 `watchdogMs` 仍无进展时把 socket 拆掉重来一次,避免"半开连接永远挂着"。
|
|
411
|
+
*/
|
|
412
|
+
#startWatchdog() {
|
|
413
|
+
this.#stopWatchdog()
|
|
414
|
+
// 检查频率:至少在"判定超时"的窗口里看两次,最多 5 秒一次、最少 1 秒一次。
|
|
415
|
+
// 不能跟着 `heartbeatMs` 走——心跳间隔设得大(例如测试里 60s)会让看门狗也 20s 才醒一次。
|
|
416
|
+
const every = Math.max(1000, Math.min(Math.floor(this.pongTimeoutMs / 2) || 1000, 5000))
|
|
417
|
+
this._watchdog = setInterval(() => {
|
|
418
|
+
if (this._stopping || this.enabled === false) return
|
|
419
|
+
const idle = this.now() - this._lastProgressAt
|
|
420
|
+
if (!this._loopActive) {
|
|
421
|
+
this.lastError = `链路循环不在了(看门狗重拉,空闲 ${Math.round(idle / 1000)}s)`
|
|
422
|
+
this.logger.warn?.(`mobile-link: ${this.lastError}`)
|
|
423
|
+
this._loop = undefined
|
|
424
|
+
this._lastProgressAt = this.now()
|
|
425
|
+
void this.start()
|
|
426
|
+
return
|
|
427
|
+
}
|
|
428
|
+
if (this.state === 'connected' && idle > this.pongTimeoutMs) {
|
|
429
|
+
this.lastError = `relay 超过 ${Math.round(idle / 1000)}s 没有动静(看门狗拆连接)`
|
|
430
|
+
this.logger.warn?.(`mobile-link: ${this.lastError}`)
|
|
431
|
+
this.#teardownSocket(this.lastError)
|
|
432
|
+
this._lastProgressAt = this.now()
|
|
433
|
+
return
|
|
434
|
+
}
|
|
435
|
+
if (this.state !== 'connected' && idle > this.watchdogMs) {
|
|
436
|
+
this.lastError = `链路 ${Math.round(idle / 1000)}s 没有进展(看门狗重建连接)`
|
|
437
|
+
this.logger.warn?.(`mobile-link: ${this.lastError}`)
|
|
438
|
+
this.#teardownSocket(this.lastError)
|
|
439
|
+
this._lastProgressAt = this.now()
|
|
440
|
+
}
|
|
441
|
+
}, every)
|
|
442
|
+
this._watchdog.unref?.()
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
#stopWatchdog() {
|
|
446
|
+
if (this._watchdog) clearInterval(this._watchdog)
|
|
447
|
+
this._watchdog = undefined
|
|
448
|
+
}
|
|
449
|
+
|
|
365
450
|
#startHeartbeat() {
|
|
366
451
|
this.#stopHeartbeat()
|
|
367
452
|
this._heartbeat = setInterval(() => {
|
|
@@ -440,9 +525,12 @@ export class MobileLinkAgent extends EventEmitter {
|
|
|
440
525
|
|
|
441
526
|
/** Route one frame from the relay. */
|
|
442
527
|
async handleRelayFrame(frame) {
|
|
528
|
+
// 中转来的任何一帧都算"链路有进展"——看门狗据此区分"半开连接"与"正在正常工作"。
|
|
529
|
+
this._lastProgressAt = this.now()
|
|
443
530
|
if (!frame || typeof frame !== 'object') return
|
|
444
531
|
if (frame.t === 'pong') {
|
|
445
532
|
this._lastPong = this.now()
|
|
533
|
+
this._lastProgressAt = this.now()
|
|
446
534
|
return
|
|
447
535
|
}
|
|
448
536
|
if (frame.t === 'ping') {
|
package/lib/qr.js
CHANGED
|
@@ -551,11 +551,19 @@ export function qrMatrix(text, { mask } = {}) {
|
|
|
551
551
|
* One path element rather than thousands of rects: small enough to print in a
|
|
552
552
|
* terminal response, and it renders identically. The four-module quiet zone is
|
|
553
553
|
* not decoration — scanners need it to find the symbol at all.
|
|
554
|
+
*
|
|
555
|
+
* `caption` draws one line under the symbol. It exists because a pairing code
|
|
556
|
+
* expires and the page is only a picture: without the line, "还剩多久" is
|
|
557
|
+
* something a person has to guess, and a guess is what leads to scanning a code
|
|
558
|
+
* that is already gone. The font stack names the CJK faces explicitly — the
|
|
559
|
+
* default SVG font renders the caption as boxes on some systems.
|
|
554
560
|
*/
|
|
555
|
-
export function qrSvg(text, { scale = 8, quietZone = 4, dark = '#111111', light = '#ffffff',
|
|
561
|
+
export function qrSvg(text, { scale = 8, quietZone = 4, dark = '#111111', light = '#ffffff',
|
|
562
|
+
mask, caption } = {}) {
|
|
556
563
|
const matrix = qrMatrix(text, mask === undefined ? {} : { mask })
|
|
557
564
|
const size = matrix.length
|
|
558
565
|
const extent = (size + quietZone * 2) * scale
|
|
566
|
+
const captionHeight = caption ? Math.round(scale * 4.5) : 0
|
|
559
567
|
const segments = []
|
|
560
568
|
for (let row = 0; row < size; row += 1) {
|
|
561
569
|
for (let column = 0; column < size; column += 1) {
|
|
@@ -565,14 +573,35 @@ export function qrSvg(text, { scale = 8, quietZone = 4, dark = '#111111', light
|
|
|
565
573
|
segments.push(`M${x} ${y}h${scale}v${scale}h-${scale}z`)
|
|
566
574
|
}
|
|
567
575
|
}
|
|
568
|
-
|
|
569
|
-
|
|
576
|
+
const captionMarkup = caption
|
|
577
|
+
? `<text x="${extent / 2}" y="${extent + Math.round(captionHeight * 0.7)}"`
|
|
578
|
+
+ ` text-anchor="middle" font-family="PingFang SC, Hiragino Sans GB, Microsoft YaHei, sans-serif"`
|
|
579
|
+
+ ` font-size="${Math.round(scale * 1.75)}" fill="${dark}">${escapeXml(caption)}</text>`
|
|
580
|
+
: ''
|
|
581
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" width="${extent}" height="${extent + captionHeight}"`
|
|
582
|
+
+ ` viewBox="0 0 ${extent} ${extent + captionHeight}" role="img"`
|
|
570
583
|
+ ` aria-label="DSH 配对二维码">`
|
|
571
|
-
+ `<rect width="${extent}" height="${extent}" fill="${light}"/>`
|
|
572
|
-
|
|
584
|
+
+ `<rect width="${extent}" height="${extent + captionHeight}" fill="${light}"/>`
|
|
585
|
+
// crispEdges belongs to the symbol, not to the document: the caption is text,
|
|
586
|
+
// and rendering *that* without antialiasing is what makes it look broken.
|
|
587
|
+
+ `<path d="${segments.join('')}" fill="${dark}" shape-rendering="crispEdges"/>`
|
|
588
|
+
+ captionMarkup
|
|
573
589
|
+ '</svg>'
|
|
574
590
|
}
|
|
575
591
|
|
|
592
|
+
/**
|
|
593
|
+
* The five characters that cannot appear literally in XML text.
|
|
594
|
+
*
|
|
595
|
+
* The caption is built from a clock and a count, but it is a parameter, so it is
|
|
596
|
+
* escaped here rather than trusted: an unescaped `&` produces an SVG the browser
|
|
597
|
+
* refuses to render at all — a pairing screen that shows nothing.
|
|
598
|
+
*/
|
|
599
|
+
function escapeXml(value) {
|
|
600
|
+
return String(value).replace(/[&<>"']/g, (ch) => ({
|
|
601
|
+
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
|
602
|
+
}[ch]))
|
|
603
|
+
}
|
|
604
|
+
|
|
576
605
|
/**
|
|
577
606
|
* The same symbol for a terminal, two matrix rows per line of text.
|
|
578
607
|
*
|