dsh-plugin-mobile-link 0.2.0 → 0.3.1

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 CHANGED
@@ -1,5 +1,12 @@
1
1
  # dsh-plugin-mobile-link
2
2
 
3
+ [![DSH plugin](https://img.shields.io/badge/DSH-plugin-4D6BFE)](https://github.com/deepseek-ai/deepseek-harness)
4
+ [![listed in awesome-deepseek-harness](https://img.shields.io/badge/listed%20in-awesome--deepseek--harness-4D6BFE?logo=awesomelists&logoColor=white)](https://github.com/Dominic789654/awesome-deepseek-harness)
5
+ [![npm](https://img.shields.io/npm/v/dsh-plugin-mobile-link)](https://www.npmjs.com/package/dsh-plugin-mobile-link)
6
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](../../LICENSE)
7
+
8
+ > 面向:使用者与实现者 · 状态:stable · 最近核对:2026-09-20
9
+
3
10
  **The computer half of [DSH Mobile](https://github.com/jayantTang/DSH_Mobile): it
4
11
  dials a relay over WSS, so the native iOS client reaches this DSH instance even
5
12
  though the computer has no public IP.**
package/lib/cli.js CHANGED
@@ -26,6 +26,7 @@ import { rename, writeFile } from 'node:fs/promises'
26
26
  import { enrollAgent, inviteFrom } from './enroll.js'
27
27
  import { MobileLinkAgent } from './link.js'
28
28
  import { mintPairCode } from './pairing.js'
29
+ import { qrTerminal } from './qr.js'
29
30
  import { INVITE_ENV, defaultStatePath, resolveIdentity } from './state.js'
30
31
 
31
32
  /** Atomic status snapshot so readers never observe a half-written file. */
@@ -89,7 +90,28 @@ async function main() {
89
90
  relayUrl: settled.relayUrl,
90
91
  stateFile: settled.stateFile,
91
92
  }, null, 2)}\n`)
92
- process.stdout.write('这台电脑已登记。重启 DSH 后连接器会使用新的身份。\n')
93
+ // 登记只是第一步:还有两步(重启、扫码)不写在输出里,新用户就会以为"登记完就好了"。
94
+ // 第一位试用用户正是这样——登记了电脑,却没有配对手机。
95
+ //
96
+ // 反正人已经坐在终端前、手机就在手边,索性把配对码也申请出来、画成终端二维码:
97
+ // 扫这一屏等于在 App 里填「中转服务器 + 配对码」,少一次开页面找端口的往返。
98
+ // 申请失败不算失败——登记本身已经成功,退回到"去二维码页取"的说法。
99
+ process.stdout.write('这台电脑已登记。接下来还有两步:\n'
100
+ + ' 1. 重启 DSH,让连接器用上这个身份;\n'
101
+ + ' 2. 手机 App 底部「扫码配对」扫下面的二维码(等同于点开二维码页取码)。\n')
102
+ try {
103
+ const identity = await resolveIdentity({ stateFile: settled.stateFile })
104
+ const minted = await mintPairCode({ identity, ttlMs: 10 * 60 * 1000 })
105
+ const minutes = Math.round((minted.expiresAt - Date.now()) / 60000)
106
+ process.stdout.write('\n' + qrTerminal(minted.qrPayload) + '\n')
107
+ process.stdout.write(`配对码 ${minted.code}(约 ${minutes} 分钟有效,一次性)\n`)
108
+ process.stdout.write('也可以在这台电脑上打开 http://127.0.0.1:<DSH 端口>/mobile-link/qr 取新码;\n'
109
+ + '端口在 DSH 启动时打印,也在 ~/.dsh/desktop-shell/endpoint.json 里。\n')
110
+ } catch (error) {
111
+ process.stdout.write('(这次没能预先申请配对码:' + (error?.message ?? error) + ')\n'
112
+ + '重启 DSH 后,在这台电脑的浏览器里打开 http://127.0.0.1:<DSH 端口>/mobile-link/qr,'
113
+ + '用手机 App 的「扫码配对」扫它。\n')
114
+ }
93
115
  return 0
94
116
  }
95
117
 
package/lib/dlp.js CHANGED
@@ -190,6 +190,15 @@ export class StreamTable {
190
190
  return this.byDlp.get(StreamTable.key(deviceId, dlpId))
191
191
  }
192
192
 
193
+ /** Every open entry for one device, in insertion order. */
194
+ byDevice(deviceId) {
195
+ const entries = []
196
+ for (const entry of this.byMux.values()) {
197
+ if (entry.deviceId === deviceId) entries.push(entry)
198
+ }
199
+ return entries
200
+ }
201
+
193
202
  entryOfMux(muxId) {
194
203
  return this.byMux.get(muxId)
195
204
  }
package/lib/git.js ADDED
@@ -0,0 +1,506 @@
1
+ /**
2
+ * Git status, diffs and history, answered on the computer itself.
3
+ *
4
+ * The Host has no git endpoint — nothing in its 47 RPC methods runs a command —
5
+ * but the connector is already a process on the machine that holds the work
6
+ * tree, and the `_link/file*` upload path proved the shape: a reserved method
7
+ * name answered here instead of being forwarded. So git runs here, in the
8
+ * session's own directory, and the phone gets what a person wants to check
9
+ * before approving an agent's work: which files changed, what changed in them,
10
+ * and what the recent commits were.
11
+ *
12
+ * **Read-only by construction.** The command whitelist below is the entire
13
+ * surface: status, diff, log, show, rev-parse, numstat. There is no way to
14
+ * commit, stage, checkout or clean through this path, and every command runs
15
+ * with `GIT_OPTIONAL_LOCKS=0` so merely looking at a repository can never leave
16
+ * an `index.lock` behind for the agent to trip over.
17
+ */
18
+
19
+ import { execFile } from 'node:child_process'
20
+ import { statSync } from 'node:fs'
21
+ import { isAbsolute, resolve } from 'node:path'
22
+
23
+ /** Reserved method names this module answers; never forwarded to the Host. */
24
+ export const GIT_STATUS = '_link/gitStatus'
25
+ export const GIT_DIFF = '_link/gitDiff'
26
+ export const GIT_LOG = '_link/gitLog'
27
+ export const GIT_SHOW = '_link/gitShow'
28
+ export const GIT_FILE = '_link/gitFile'
29
+
30
+ const METHODS = new Set([GIT_STATUS, GIT_DIFF, GIT_LOG, GIT_SHOW, GIT_FILE])
31
+
32
+ export function isGitMethod(method) {
33
+ return METHODS.has(method)
34
+ }
35
+
36
+ /** Output ceilings: a phone review, not a `git log | less`. */
37
+ export const LIMITS = {
38
+ statusBytes: 1024 * 1024,
39
+ diffBytes: 256 * 1024,
40
+ logBytes: 512 * 1024,
41
+ fileBytes: 8 * 1024 * 1024,
42
+ logPage: 20,
43
+ statusTimeoutMs: 15_000,
44
+ readTimeoutMs: 15_000,
45
+ }
46
+
47
+ /** The environment every git call runs in. */
48
+ function gitEnv() {
49
+ return {
50
+ ...process.env,
51
+ // Never wait for a credential prompt or open an editor: a phone review
52
+ // must not be able to wedge a process on the computer.
53
+ GIT_TERMINAL_PROMPT: '0',
54
+ GIT_PAGER: 'cat',
55
+ GIT_EDITOR: 'true',
56
+ // Looking at a repository must not take its locks — the agent may be
57
+ // working in it at the same moment.
58
+ GIT_OPTIONAL_LOCKS: '0',
59
+ // Stable field order and dates; the parsers below depend on it.
60
+ LC_ALL: 'C',
61
+ }
62
+ }
63
+
64
+ /** A refusal the phone can branch on, rather than a string to match. */
65
+ export class GitError extends Error {
66
+ constructor(code, message, details = {}) {
67
+ super(message)
68
+ this.code = code
69
+ this.details = details
70
+ }
71
+ }
72
+
73
+ /**
74
+ * Runs one git command in `cwd`.
75
+ *
76
+ * `okCodes` exists because `git diff` answers 1 when it found differences —
77
+ * which is the normal case here, not a failure.
78
+ */
79
+ function runGit(cwd, argv, { timeoutMs, maxBytes, okCodes = [0] } = {}) {
80
+ return new Promise((resolvePromise, reject) => {
81
+ execFile(
82
+ 'git',
83
+ ['-C', cwd, '--no-pager', '-c', 'core.quotepath=false', ...argv],
84
+ {
85
+ timeout: timeoutMs ?? LIMITS.readTimeoutMs,
86
+ maxBuffer: (maxBytes ?? LIMITS.diffBytes) + 1024,
87
+ env: gitEnv(),
88
+ encoding: 'buffer',
89
+ },
90
+ (error, stdout, stderr) => {
91
+ const out = Buffer.isBuffer(stdout) ? stdout : Buffer.from(stdout ?? '')
92
+ if (error) {
93
+ // `execFile` reports a spawn failure (no git installed) as ENOENT.
94
+ if (error.code === 'ENOENT') {
95
+ reject(new GitError('git/unavailable', '这台电脑上没有找到 git'))
96
+ return
97
+ }
98
+ if (error.killed || error.signal) {
99
+ reject(new GitError('git/timed-out', 'git 命令超过时限,已中止'))
100
+ return
101
+ }
102
+ if (error.code === 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER') {
103
+ reject(new GitError('git/too-large', '命令输出超过上限,请缩小范围'))
104
+ return
105
+ }
106
+ const code = typeof error.code === 'number' ? error.code : 1
107
+ if (okCodes.includes(code)) {
108
+ resolvePromise(out)
109
+ return
110
+ }
111
+ const text = (Buffer.isBuffer(stderr) ? stderr : Buffer.from(stderr ?? '')).toString('utf8')
112
+ reject(new GitError('git/failed', text.trim().split('\n').slice(-1)[0] || 'git 命令失败', {
113
+ exitCode: code,
114
+ }))
115
+ return
116
+ }
117
+ resolvePromise(out)
118
+ }
119
+ )
120
+ })
121
+ }
122
+
123
+ /** Resolves a directory that is inside a work tree, and that tree's root. */
124
+ async function repository(cwd) {
125
+ const directory = String(cwd ?? '')
126
+ if (!directory || !isAbsolute(directory)) {
127
+ throw new GitError('git/bad-request', '需要一个绝对路径的工作目录')
128
+ }
129
+ let stats
130
+ try {
131
+ stats = statSync(resolve(directory))
132
+ } catch {
133
+ throw new GitError('git/not-found', `目录不在了:${directory}`)
134
+ }
135
+ if (!stats.isDirectory()) {
136
+ throw new GitError('git/bad-request', `不是一个目录:${directory}`)
137
+ }
138
+ let root
139
+ try {
140
+ root = (await runGit(directory, ['rev-parse', '--show-toplevel'], {
141
+ timeoutMs: LIMITS.statusTimeoutMs,
142
+ }))
143
+ .toString('utf8')
144
+ .trim()
145
+ } catch (error) {
146
+ // `rev-parse` exits 128 outside a work tree; that is a state to report, not
147
+ // a failure of the command.
148
+ if (error instanceof GitError && error.code === 'git/failed') {
149
+ throw new GitError('git/not-a-repo', '这个目录不在 git 仓库里', { path: directory })
150
+ }
151
+ throw error
152
+ }
153
+ if (!root) throw new GitError('git/not-a-repo', '这个目录不在 git 仓库里', { path: directory })
154
+ return { directory, root }
155
+ }
156
+
157
+ /** One repository-relative path, as the phone sends it. */
158
+ function safePath(value, { required = true } = {}) {
159
+ const path = String(value ?? '').trim()
160
+ if (!path) {
161
+ if (!required) return ''
162
+ throw new GitError('git/bad-request', '缺少文件路径')
163
+ }
164
+ if (isAbsolute(path) || path.split('/').includes('..')) {
165
+ throw new GitError('git/bad-request', `路径必须是仓库内的相对路径:${path}`)
166
+ }
167
+ return path
168
+ }
169
+
170
+ /** Whether this revision exists at all (a fresh repository has no HEAD). */
171
+ async function headExists(root) {
172
+ try {
173
+ await runGit(root, ['rev-parse', '--verify', 'HEAD'])
174
+ return true
175
+ } catch {
176
+ return false
177
+ }
178
+ }
179
+
180
+ // ── status ─────────────────────────────────────────────────────────────────
181
+
182
+ /**
183
+ * `git status --porcelain=v2 -z --branch`.
184
+ *
185
+ * Version 2 with NUL separators, not the human format: it names the index and
186
+ * worktree sides separately (so "staged" and "not staged" are facts, not
187
+ * guesses), reports renames with both paths, and survives newlines in file
188
+ * names — which the line-based format does not.
189
+ */
190
+ export function parseStatus(buffer) {
191
+ const records = buffer.toString('utf8').split('\0')
192
+ const files = []
193
+ let branch = { head: null, oid: null, upstream: null, ahead: 0, behind: 0, detached: false }
194
+
195
+ for (let index = 0; index < records.length; index += 1) {
196
+ const record = records[index]
197
+ if (!record) continue
198
+ if (record.startsWith('# branch.oid ')) {
199
+ branch.oid = record.slice('# branch.oid '.length).trim()
200
+ if (branch.oid === '(initial)') branch.oid = null
201
+ continue
202
+ }
203
+ if (record.startsWith('# branch.head ')) {
204
+ const head = record.slice('# branch.head '.length).trim()
205
+ branch.detached = head === '(detached)'
206
+ branch.head = branch.detached ? null : head
207
+ continue
208
+ }
209
+ if (record.startsWith('# branch.upstream ')) {
210
+ branch.upstream = record.slice('# branch.upstream '.length).trim()
211
+ continue
212
+ }
213
+ if (record.startsWith('# branch.ab ')) {
214
+ // `# branch.ab +2 -1`: the counters are the third and fourth fields.
215
+ const parts = record.split(/\s+/)
216
+ branch.ahead = Number(String(parts[2] ?? '').replace('+', '')) || 0
217
+ branch.behind = Number(String(parts[3] ?? '').replace('-', '')) || 0
218
+ continue
219
+ }
220
+ if (record.startsWith('1 ')) {
221
+ const parts = record.split(' ')
222
+ // 1 <XY> <sub> <mH> <mI> <mW> <hH> <hI> <path>
223
+ files.push(entry(parts[1], parts.slice(8).join(' '), null))
224
+ continue
225
+ }
226
+ if (record.startsWith('2 ')) {
227
+ const parts = record.split(' ')
228
+ // 2 <XY> <sub> <mH> <mI> <mW> <hH> <hI> <X><score> <path>
229
+ const path = parts.slice(9).join(' ')
230
+ // The original path is the next NUL-separated field.
231
+ const original = records[index + 1] ?? ''
232
+ index += 1
233
+ files.push(entry(parts[1], path, original))
234
+ continue
235
+ }
236
+ if (record.startsWith('u ')) {
237
+ const parts = record.split(' ')
238
+ // u <XY> <sub> <m1> <m2> <m3> <mW> <h1> <h2> <h3> <path>
239
+ files.push({ ...entry(parts[1], parts.slice(10).join(' '), null), conflicted: true })
240
+ continue
241
+ }
242
+ if (record.startsWith('? ')) {
243
+ files.push({ path: record.slice(2), originalPath: null, index: '.', worktree: '?', kind: 'untracked', staged: false, unstaged: true, conflicted: false })
244
+ continue
245
+ }
246
+ }
247
+ return { branch, files }
248
+ }
249
+
250
+ function entry(xy, path, originalPath) {
251
+ const index = xy?.[0] ?? '.'
252
+ const worktree = xy?.[1] ?? '.'
253
+ return {
254
+ path,
255
+ originalPath: originalPath || null,
256
+ index,
257
+ worktree,
258
+ kind: kindOf(index, worktree),
259
+ staged: index !== '.' && index !== '?',
260
+ unstaged: worktree !== '.' && worktree !== '?',
261
+ conflicted: false,
262
+ }
263
+ }
264
+
265
+ function kindOf(index, worktree) {
266
+ const code = worktree !== '.' ? worktree : index
267
+ switch (code) {
268
+ case 'A': return 'added'
269
+ case 'D': return 'deleted'
270
+ case 'R': return 'renamed'
271
+ case 'C': return 'copied'
272
+ case 'T': return 'typechange'
273
+ case 'M': return 'modified'
274
+ default: return 'modified'
275
+ }
276
+ }
277
+
278
+ // ── log ────────────────────────────────────────────────────────────────────
279
+
280
+ const LOG_FORMAT = '%H%x1f%h%x1f%an%x1f%aI%x1f%s%x1f%D'
281
+
282
+ /** `git log -z --format=…`: one NUL-separated record per commit. */
283
+ export function parseLog(buffer) {
284
+ return buffer
285
+ .toString('utf8')
286
+ .split('\0')
287
+ .map((record) => record.replace(/^\n+/, ''))
288
+ .filter((record) => record.trim())
289
+ .map((record) => {
290
+ const [sha, short, author, date, subject, refs] = record.split('\u001f')
291
+ return {
292
+ sha,
293
+ short,
294
+ author,
295
+ date,
296
+ subject,
297
+ refs: refs ? refs.split(', ').filter(Boolean) : [],
298
+ }
299
+ })
300
+ }
301
+
302
+ // ── show ───────────────────────────────────────────────────────────────────
303
+
304
+ /**
305
+ * `--numstat`: additions, deletions and path per file; `-` means binary.
306
+ *
307
+ * Called with `-z`, so records are NUL-separated and a path may contain
308
+ * anything — including a newline. The `\n` fallback keeps the parser usable on
309
+ * captured output that was not produced with `-z`.
310
+ */
311
+ export function parseNumstat(buffer) {
312
+ const text = buffer.toString('utf8')
313
+ return text
314
+ .split(text.includes('\0') ? '\0' : '\n')
315
+ .filter((line) => line.trim())
316
+ .map((line) => {
317
+ const [added, deleted, ...rest] = line.split('\t')
318
+ let path = rest.join('\t')
319
+ // A rename shows as `old => new` (or `dir/{old => new}/file`).
320
+ let originalPath = null
321
+ const brace = /\{(.*?) => (.*?)\}/.exec(path)
322
+ if (brace) {
323
+ originalPath = path.replace(/\{(.*?) => (.*?)\}/, '$1')
324
+ path = path.replace(/\{(.*?) => (.*?)\}/, '$2')
325
+ } else if (path.includes(' => ')) {
326
+ const [from, to] = path.split(' => ')
327
+ originalPath = from
328
+ path = to
329
+ }
330
+ return {
331
+ path,
332
+ originalPath,
333
+ additions: added === '-' ? null : Number(added),
334
+ deletions: deleted === '-' ? null : Number(deleted),
335
+ binary: added === '-' && deleted === '-',
336
+ }
337
+ })
338
+ }
339
+
340
+ // ── the bridge ─────────────────────────────────────────────────────────────
341
+
342
+ export class GitBridge {
343
+ #logger
344
+
345
+ constructor(logger) {
346
+ this.#logger = logger
347
+ }
348
+
349
+ /** Answers one reserved call. Throws `GitError` for the router to report. */
350
+ async handle(method, args = {}) {
351
+ switch (method) {
352
+ case GIT_STATUS: return this.#status(args)
353
+ case GIT_DIFF: return this.#diff(args)
354
+ case GIT_LOG: return this.#log(args)
355
+ case GIT_SHOW: return this.#show(args)
356
+ case GIT_FILE: return this.#file(args)
357
+ default: throw new GitError('git/bad-request', `未知的 git 调用:${method}`)
358
+ }
359
+ }
360
+
361
+ async #status(args) {
362
+ const { directory, root } = await repository(args.cwd)
363
+ const buffer = await runGit(
364
+ directory,
365
+ ['status', '--porcelain=v2', '-z', '--branch', '--untracked-files=all'],
366
+ { timeoutMs: LIMITS.statusTimeoutMs, maxBytes: LIMITS.statusBytes }
367
+ )
368
+ const parsed = parseStatus(buffer)
369
+ this.#logger?.debug?.(`mobile-link: git status ${root} — ${parsed.files.length} 处改动`)
370
+ return {
371
+ root,
372
+ insideWorkTree: true,
373
+ hasHead: await headExists(root),
374
+ branch: parsed.branch,
375
+ files: parsed.files,
376
+ truncated: buffer.length >= LIMITS.statusBytes,
377
+ }
378
+ }
379
+
380
+ async #diff(args) {
381
+ const { directory, root } = await repository(args.cwd)
382
+ const path = safePath(args.path)
383
+ const staged = args.staged === true
384
+ // An untracked file has no baseline to diff against: `--no-index` against
385
+ // /dev/null renders it as the pure addition it is. Exit code 1 is "there
386
+ // were differences", which is the normal answer here.
387
+ const untracked = await this.#isUntracked(directory, path)
388
+ const argv = untracked
389
+ ? ['diff', '--no-color', '--no-index', `--unified=${clampContext(args.context)}`, '--', '/dev/null', path]
390
+ : ['diff', '--no-color', `--unified=${clampContext(args.context)}`, ...(staged ? ['--cached'] : []), '--', path]
391
+ const buffer = await runGit(directory, argv, {
392
+ maxBytes: LIMITS.diffBytes + 1024,
393
+ okCodes: [0, 1],
394
+ })
395
+ const text = buffer.toString('utf8')
396
+ const binary = /^Binary files .* differ$/m.test(text) || /^GIT binary patch$/m.test(text)
397
+ return {
398
+ root,
399
+ path,
400
+ staged,
401
+ untracked,
402
+ binary,
403
+ text: binary ? '' : text.slice(0, LIMITS.diffBytes),
404
+ truncated: !binary && buffer.length > LIMITS.diffBytes,
405
+ }
406
+ }
407
+
408
+ async #isUntracked(directory, path) {
409
+ const buffer = await runGit(directory, ['status', '--porcelain=v1', '-z', '--', path], {
410
+ timeoutMs: LIMITS.statusTimeoutMs,
411
+ })
412
+ return buffer.toString('utf8').startsWith('?? ')
413
+ }
414
+
415
+ async #log(args) {
416
+ const { directory, root } = await repository(args.cwd)
417
+ if (!(await headExists(root))) {
418
+ throw new GitError('git/no-commits', '这个仓库还没有任何提交', { root })
419
+ }
420
+ const page = Math.min(Math.max(Number(args.limit) || LIMITS.logPage, 1), 50)
421
+ const skip = Math.max(Number(args.skip) || 0, 0)
422
+ const buffer = await runGit(
423
+ directory,
424
+ ['log', '-z', `--max-count=${page + 1}`, `--skip=${skip}`, `--format=${LOG_FORMAT}`],
425
+ { maxBytes: LIMITS.logBytes }
426
+ )
427
+ const all = parseLog(buffer)
428
+ const hasMore = all.length > page
429
+ return { root, commits: all.slice(0, page), hasMore, skip }
430
+ }
431
+
432
+ async #show(args) {
433
+ const { directory, root } = await repository(args.cwd)
434
+ const sha = String(args.sha ?? '').trim()
435
+ if (!/^[0-9a-fA-F]{4,40}$/.test(sha)) {
436
+ throw new GitError('git/bad-request', '需要一个提交号')
437
+ }
438
+ const path = safePath(args.path, { required: false })
439
+
440
+ if (path) {
441
+ const buffer = await runGit(
442
+ directory,
443
+ ['show', '--no-color', `--unified=${clampContext(args.context)}`, '--format=', sha, '--', path],
444
+ { maxBytes: LIMITS.diffBytes + 1024, okCodes: [0, 1] }
445
+ )
446
+ const text = buffer.toString('utf8')
447
+ const binary = /^Binary files .* differ$/m.test(text) || /^GIT binary patch$/m.test(text)
448
+ return {
449
+ root,
450
+ sha,
451
+ path,
452
+ binary,
453
+ text: binary ? '' : text.slice(0, LIMITS.diffBytes),
454
+ truncated: !binary && buffer.length > LIMITS.diffBytes,
455
+ }
456
+ }
457
+
458
+ const meta = parseLog(
459
+ await runGit(directory, ['show', '-s', '-z', `--format=${LOG_FORMAT}`, sha], { okCodes: [0, 1] })
460
+ )[0] ?? { sha }
461
+ const files = parseNumstat(
462
+ await runGit(directory, ['show', '--numstat', '--format=', '-z', sha], {
463
+ maxBytes: LIMITS.statusBytes,
464
+ okCodes: [0, 1],
465
+ })
466
+ )
467
+ return { root, commit: meta, files }
468
+ }
469
+
470
+ async #file(args) {
471
+ const { directory, root } = await repository(args.cwd)
472
+ const sha = String(args.rev ?? args.sha ?? '').trim()
473
+ if (!sha || !/^[0-9a-zA-Z._/^~-]{1,80}$/.test(sha)) {
474
+ throw new GitError('git/bad-request', '需要一个版本号')
475
+ }
476
+ const path = safePath(args.path)
477
+ const size = Number(
478
+ (await runGit(directory, ['cat-file', '-s', `${sha}:${path}`], { okCodes: [0, 1] }))
479
+ .toString('utf8')
480
+ .trim()
481
+ )
482
+ if (Number.isFinite(size) && size > LIMITS.fileBytes) {
483
+ throw new GitError('git/too-large', `这个版本的文件有 ${size} 字节,超过手机上直接打开的上限`, {
484
+ bytes: size,
485
+ limit: LIMITS.fileBytes,
486
+ })
487
+ }
488
+ const buffer = await runGit(directory, ['show', `${sha}:${path}`], {
489
+ maxBytes: LIMITS.fileBytes + 1024,
490
+ okCodes: [0, 1],
491
+ })
492
+ return {
493
+ root,
494
+ rev: sha,
495
+ path,
496
+ bytes: buffer.length,
497
+ data: buffer.toString('base64'),
498
+ }
499
+ }
500
+ }
501
+
502
+ function clampContext(value) {
503
+ const context = Number(value)
504
+ if (!Number.isFinite(context)) return 3
505
+ return Math.min(Math.max(Math.trunc(context), 0), 10)
506
+ }
package/lib/hello.js CHANGED
@@ -44,15 +44,44 @@ function readManifestVersion() {
44
44
  * - `qr-pairing` this connector can render the pairing QR itself
45
45
  * (`GET /mobile-link/qr`), so the desktop needs no client
46
46
  * - `self-enroll` this connector can register itself with an invite code
47
+ * - `git` git status / diff / log / show can be read from the work
48
+ * tree on this computer (see git.js)
47
49
  */
48
50
  export const SERVER_CAPABILITIES = [
49
- 'file-transfer', 'events', 'session-streams', 'pair-code', 'qr-pairing', 'self-enroll',
51
+ 'file-transfer', 'events', 'session-streams', 'pair-code', 'qr-pairing', 'self-enroll', 'git',
50
52
  ]
51
53
 
52
54
  export function isHelloMethod(method) {
53
55
  return method === HELLO_METHOD
54
56
  }
55
57
 
58
+ /**
59
+ * What the phone says it is, out of the handshake arguments.
60
+ *
61
+ * The app sends this on every connection — the only place the *client's* build
62
+ * travels, since the relay's device row is otherwise written once at pairing and
63
+ * then never again. Older clients send `{}`, which yields nulls: reporting
64
+ * "unknown" beats reporting a version that may be days old.
65
+ *
66
+ * Exported for testing: what it returns ends up in a log line a person will read
67
+ * when they ask "did that phone update?".
68
+ */
69
+ export function parseClientInfo(args) {
70
+ const text = (value) => (typeof value === 'string' && value.trim().length > 0 ? value.trim() : null)
71
+ const name = text(args?.clientName)
72
+ const version = text(args?.clientVersion)
73
+ const build = text(args?.clientBuild)
74
+ if (!name && !version && !build) return null
75
+ return {
76
+ name,
77
+ version,
78
+ build,
79
+ // `DSHMobile 1.0 (20260919.0005)`, skipping whatever is missing.
80
+ label: [name, version ? `${version}${build ? ` (${build})` : ''}` : build].filter(Boolean).join(' '),
81
+ at: Date.now(),
82
+ }
83
+ }
84
+
56
85
  /** The answer to {@link HELLO_METHOD}. */
57
86
  export function helloPayload({ protocolVersion, agentId, name } = {}) {
58
87
  return {
package/lib/link.js CHANGED
@@ -10,7 +10,10 @@ import { DshClient } from './dsh-client.js'
10
10
  import { agentEndpoint, decodeFrame, encodeFrame, nextBackoff, normalizeRelayUrl, pongFor } from './dlp.js'
11
11
  import { enrollAgent, enrollCommand, inviteFrom } from './enroll.js'
12
12
  import { mintPairCode } from './pairing.js'
13
- import { EVENTS_ENDPOINT, EVENTS_RESULT, DeviceRouter, messageOf } from './router.js'
13
+ import {
14
+ DEFAULT_EVENTS_BACKLOG, DEFAULT_EVENTS_GRACE_MS,
15
+ EVENTS_ENDPOINT, EVENTS_RESULT, DeviceRouter, messageOf,
16
+ } from './router.js'
14
17
  import { SERVER_VERSION, SERVER_CAPABILITIES } from './hello.js'
15
18
  import { MissingIdentity, resolveIdentity } from './state.js'
16
19
  import { connect as wsConnect } from './ws.js'
@@ -28,12 +31,24 @@ function sleep(ms, signal) {
28
31
  })
29
32
  }
30
33
 
34
+ /** Read a positive number from the environment; anything else falls back. */
35
+ function numberFromEnv(raw, fallback) {
36
+ if (raw === undefined || raw === null || String(raw).trim() === '') return fallback
37
+ const value = Number(raw)
38
+ return Number.isFinite(value) && value >= 0 ? value : fallback
39
+ }
40
+
31
41
  export class MobileLinkAgent extends EventEmitter {
32
42
  constructor({
33
43
  relayUrl, agentId, agentSecret, agentName, stateFile, dshUrl, endpointFile,
34
44
  logger = console, heartbeatMs = 20000, pongTimeoutMs = 60000, maxBackoffMs = 30000,
35
45
  random = Math.random, now = Date.now, dshClient, connectImpl = wsConnect, enabled = true,
36
46
  inviteCode, fetchImpl = fetch, env = process.env,
47
+ // 「手机不在时替它留着 $events 流」的宽限期与缓冲上限(见 router.js 的说明);
48
+ // 环境变量给部署用,构造参数给测试用。
49
+ eventsGraceMs = numberFromEnv(env.DSH_MOBILE_LINK_EVENTS_GRACE_MS, DEFAULT_EVENTS_GRACE_MS),
50
+ eventsBacklog = numberFromEnv(env.DSH_MOBILE_LINK_EVENTS_BACKLOG, DEFAULT_EVENTS_BACKLOG),
51
+ setTimeoutFn = setTimeout, clearTimeoutFn = clearTimeout,
37
52
  } = {}) {
38
53
  super()
39
54
  this.config = { relayUrl, agentId, agentSecret, agentName, stateFile, dshUrl, endpointFile, inviteCode }
@@ -56,6 +71,10 @@ export class MobileLinkAgent extends EventEmitter {
56
71
  logger,
57
72
  now,
58
73
  protocolVersion: PROTOCOL_VERSION,
74
+ eventsGraceMs,
75
+ eventsBacklog,
76
+ setTimeoutFn,
77
+ clearTimeoutFn,
59
78
  send: (deviceId, frame) => this.sendToDevice(deviceId, frame),
60
79
  onChange: () => this.emit('status'),
61
80
  })
package/lib/qr.js CHANGED
@@ -573,4 +573,33 @@ export function qrSvg(text, { scale = 8, quietZone = 4, dark = '#111111', light
573
573
  + '</svg>'
574
574
  }
575
575
 
576
+ /**
577
+ * The same symbol for a terminal, two matrix rows per line of text.
578
+ *
579
+ * Why bother: `enroll` is the one moment a person is already sitting at the
580
+ * terminal with the phone in hand, and a code printed there can be scanned
581
+ * straight off the screen — no page to open, no port to look up. Half blocks
582
+ * ('▀' = top dark, '▄' = bottom dark) keep the symbol roughly square in a
583
+ * terminal cell, which is twice as tall as it is wide. The quiet zone is the
584
+ * spec's four modules, not the two this first shipped with: at two, Vision —
585
+ * the same decoder iOS uses — refused to find the symbol at all.
586
+ */
587
+ export function qrTerminal(text, { quietZone = 4, margin = ' ' } = {}) {
588
+ const matrix = qrMatrix(text)
589
+ const size = matrix.length
590
+ const blank = (row) => row < 0 || row >= size
591
+ const dark = (row, column) => !blank(row) && column >= 0 && column < size && matrix[row][column]
592
+ const lines = []
593
+ for (let row = -quietZone; row < size + quietZone; row += 2) {
594
+ let line = margin
595
+ for (let column = -quietZone; column < size + quietZone; column += 1) {
596
+ const top = dark(row, column)
597
+ const bottom = dark(row + 1, column)
598
+ line += top && bottom ? '█' : top ? '▀' : bottom ? '▄' : ' '
599
+ }
600
+ lines.push(line)
601
+ }
602
+ return lines.join('\n')
603
+ }
604
+
576
605
  export { TOTAL_CODEWORDS }
package/lib/router.js CHANGED
@@ -13,11 +13,39 @@ import {
13
13
  StreamTable, WaterfallDedupe, deviceFrameError, deviceIdOf, nonEmptyString, resultFrame,
14
14
  } from './dlp.js'
15
15
  import { FileInbox, isFileMethod } from './files.js'
16
- import { isHelloMethod, helloPayload } from './hello.js'
16
+ import { GitBridge, isGitMethod } from './git.js'
17
+ import { isHelloMethod, helloPayload, parseClientInfo } from './hello.js'
17
18
 
18
19
  export const EVENTS_ENDPOINT = '$events'
19
20
  export const EVENTS_RESULT = '$events/result'
20
21
 
22
+ /**
23
+ * 手机断了之后,这条 `$events` 流还替它留多久(毫秒)。
24
+ *
25
+ * 为什么需要:手机是唯一客户端时,它一断,DSH 那边就没人持有这条流了——
26
+ * 提问(waterfall)会随 Agent Context 释放被撤掉,用户回到手机再也看不到
27
+ * (根因见 maintainers/PENDING-WATERFALL-LOSS.md)。留着流就等于"代手机值班":
28
+ * 这期间来的提问先攒着,手机回来补发。
29
+ *
30
+ * 代价是这段时间里 DSH 认为"设备还在",会话的 Agent Context 不会因此释放;
31
+ * 所以必须有上限,不能无限留。
32
+ */
33
+ export const DEFAULT_EVENTS_GRACE_MS = 15 * 60 * 1000
34
+
35
+ /** 离线期间最多替一台设备攒多少条 `$events` 项(只攒 waterfall/cancel)。 */
36
+ export const DEFAULT_EVENTS_BACKLOG = 50
37
+
38
+ /**
39
+ * 手机不在时要攒下来的 `$events` 项:只有"待回答的提问"和"提问作废"两种。
40
+ *
41
+ * 别的一律不攒——emit 类的都是"某事刚发生"的通知,几分钟后补发只会让 App
42
+ * 对着旧事件发通知(例如一条早就结束的 turn)。
43
+ */
44
+ export function isBufferedEvent(value) {
45
+ if (!value || typeof value !== 'object') return false
46
+ return value.type === 'waterfall' || value.type === 'cancel'
47
+ }
48
+
21
49
  export function messageOf(error) {
22
50
  return error instanceof Error ? error.message : String(error)
23
51
  }
@@ -33,8 +61,16 @@ export class DeviceRouter {
33
61
  * @param {(deviceId: string, frame: object) => boolean} options.send deliver one frame to one device
34
62
  * @param {() => void} [options.onChange] called whenever the status snapshot changes
35
63
  */
36
- constructor({ dsh, send, logger = console, onChange = () => {}, now = Date.now, protocolVersion = 1 } = {}) {
64
+ constructor({
65
+ dsh, send, logger = console, onChange = () => {}, now = Date.now, protocolVersion = 1,
66
+ eventsGraceMs = DEFAULT_EVENTS_GRACE_MS, eventsBacklog = DEFAULT_EVENTS_BACKLOG,
67
+ setTimeoutFn = setTimeout, clearTimeoutFn = clearTimeout,
68
+ } = {}) {
37
69
  this.dsh = dsh
70
+ this.eventsGraceMs = eventsGraceMs
71
+ this.eventsBacklog = eventsBacklog
72
+ this.setTimeoutFn = setTimeoutFn
73
+ this.clearTimeoutFn = clearTimeoutFn
38
74
  // Reported to the app so it can tell which wire revision it is talking to.
39
75
  this.protocolVersion = protocolVersion
40
76
  this.send = send
@@ -46,6 +82,7 @@ export class DeviceRouter {
46
82
  this.dedupe = new WaterfallDedupe({ now })
47
83
  this.lastError = undefined
48
84
  this.fileInbox = new FileInbox(logger)
85
+ this.git = new GitBridge(logger)
49
86
  }
50
87
 
51
88
  #changed() {
@@ -98,7 +135,20 @@ export class DeviceRouter {
98
135
  // ── device lifecycle ────────────────────────────────────────────────────
99
136
 
100
137
  async attachDevice(deviceId, info = {}) {
101
- if (!nonEmptyString(deviceId) || this.devices.has(deviceId)) return
138
+ if (!nonEmptyString(deviceId)) return
139
+ const existing = this.devices.get(deviceId)
140
+ if (existing) {
141
+ // 回头客:宽限期内又连上了。流和攒下的提问都还在,交给 handleOpen 补发。
142
+ if (existing.offlineSince !== undefined) {
143
+ this.clearTimeoutFn(existing.offlineTimer)
144
+ existing.offlineTimer = undefined
145
+ existing.offlineSince = undefined
146
+ this.logger.info?.(
147
+ `mobile-link: device ${deviceId} reattached with ${existing.eventsPending.size} unanswered question(s)`)
148
+ this.#changed()
149
+ }
150
+ return
151
+ }
102
152
  this.devices.set(deviceId, {
103
153
  deviceId,
104
154
  name: info?.name,
@@ -108,6 +158,13 @@ export class DeviceRouter {
108
158
  readyValue: undefined,
109
159
  eventsMuxId: undefined,
110
160
  dlpIds: new Set(),
161
+ // 手机不在时的"值班"状态:offlineSince 有值 = 这条流在替它留着;
162
+ // eventsPending 是**还没被回答**的提问(按 eventId 存)。
163
+ // 注意它不是一个"送一次就清空"的队列:送过也要留着,否则 App 再重启一次
164
+ // (或崩一次)这条提问就永远丢了——2026-09-22 验收时正是这么发现的。
165
+ offlineSince: undefined,
166
+ offlineTimer: undefined,
167
+ eventsPending: new Map(),
111
168
  })
112
169
  this.logger.info?.(`mobile-link: device ${deviceId} attached (${info?.name ?? 'unknown'})`)
113
170
  this.#changed()
@@ -117,9 +174,42 @@ export class DeviceRouter {
117
174
  async detachDevice(deviceId, reason) {
118
175
  const record = this.devices.get(deviceId)
119
176
  if (!record) return
177
+ // 除了 `$events`,其它流(文件、会话流)立刻撤掉:它们没有"值班"的意义,
178
+ // 留着只会占资源。`$events` 留着,见 DEFAULT_EVENTS_GRACE_MS 的说明。
179
+ const keep = []
180
+ for (const entry of this.streams.byDevice(deviceId)) {
181
+ if (entry.endpoint === EVENTS_ENDPOINT) {
182
+ keep.push(entry)
183
+ continue
184
+ }
185
+ this.streams.removeByMux(entry.muxId)
186
+ this.dsh.cancelStream(entry.muxId)
187
+ }
188
+ record.dlpIds.clear()
189
+ for (const entry of keep) record.dlpIds.add(entry.dlpId)
190
+
191
+ if (record.offlineSince === undefined) {
192
+ record.offlineSince = this.now()
193
+ record.offlineTimer = this.setTimeoutFn(() => {
194
+ void this.#expireOfflineDevice(deviceId)
195
+ }, this.eventsGraceMs)
196
+ // 定时器不该把进程钉住(CLI 与测试里尤其明显)。
197
+ record.offlineTimer?.unref?.()
198
+ }
199
+ this.logger.info?.(
200
+ `mobile-link: device ${deviceId} detached (${reason ?? 'closed'});`
201
+ + `$events 流替它留 ${Math.round(this.eventsGraceMs / 1000)} 秒,期间来的提问会攒着`)
202
+ this.#changed()
203
+ }
204
+
205
+ /** 宽限期到点:真的把这台设备放下(撤流、清缓冲)。 */
206
+ async #expireOfflineDevice(deviceId) {
207
+ const record = this.devices.get(deviceId)
208
+ if (!record || record.offlineSince === undefined) return
120
209
  for (const muxId of this.streams.removeDevice(deviceId)) this.dsh.cancelStream(muxId)
121
210
  this.devices.delete(deviceId)
122
- this.logger.info?.(`mobile-link: device ${deviceId} detached (${reason ?? 'closed'})`)
211
+ this.logger.info?.(
212
+ `mobile-link: device ${deviceId} 宽限期结束,已放下(丢掉 ${record.eventsPending.size} 条没人回答的提问)`)
123
213
  this.#changed()
124
214
  }
125
215
 
@@ -146,6 +236,8 @@ export class DeviceRouter {
146
236
  teardownAll() {
147
237
  const muxIds = []
148
238
  for (const deviceId of [...this.devices.keys()]) {
239
+ const record = this.devices.get(deviceId)
240
+ this.clearTimeoutFn(record?.offlineTimer)
149
241
  muxIds.push(...this.streams.removeDevice(deviceId))
150
242
  this.devices.delete(deviceId)
151
243
  }
@@ -162,6 +254,23 @@ export class DeviceRouter {
162
254
  // What this connector is and can do. Answered here for the same reason as
163
255
  // the file calls: the HTTP status route exists only on the direct path.
164
256
  if (isHelloMethod(frame.method)) {
257
+ // Recorded before answering: this is the only frame in which the phone
258
+ // says which build it is, and the relay cannot see it from the pairing
259
+ // row it keeps.
260
+ const client = parseClientInfo(frame.args)
261
+ if (client) {
262
+ const record = this.devices.get(deviceId)
263
+ if (record) record.client = client
264
+ if (record?.client?.build !== client.build || record?.clientLogged !== client.build) {
265
+ if (record) record.clientLogged = client.build
266
+ // One line per build per connection: enough to answer "did they
267
+ // update?" from a log, without a line per reconnect.
268
+ this.logger?.info?.(
269
+ `mobile-link: device ${deviceId} is ${client.label}` +
270
+ `${record?.name ? ` (${record.name})` : ''}`
271
+ )
272
+ }
273
+ }
165
274
  const value = helloPayload({
166
275
  protocolVersion: this.protocolVersion,
167
276
  agentId: this.identity?.agentId,
@@ -182,6 +291,24 @@ export class DeviceRouter {
182
291
  return
183
292
  }
184
293
 
294
+ // Git also runs here, on the machine that holds the work tree: the Host has
295
+ // no endpoint that runs a command, and the review a person wants — what
296
+ // changed, what the diff says, what the last commits were — is all local.
297
+ // Read-only; see git.js for the command whitelist.
298
+ if (isGitMethod(frame.method)) {
299
+ let result
300
+ try {
301
+ result = { ok: true, value: await this.git.handle(frame.method, frame.args ?? {}) }
302
+ } catch (error) {
303
+ result = {
304
+ ok: false,
305
+ error: errorObject(error?.code ?? 'git/failed', messageOf(error), error?.details ?? {}),
306
+ }
307
+ }
308
+ this.send(deviceId, resultFrame(frame.id, result, deviceId))
309
+ return
310
+ }
311
+
185
312
  let result
186
313
  try {
187
314
  result = await this.dsh.rpc(frame.method, frame.args ?? {})
@@ -216,6 +343,17 @@ export class DeviceRouter {
216
343
  if (record.readyValue !== undefined) {
217
344
  this.send(deviceId, { t: 'item', id: frame.id, value: record.readyValue })
218
345
  }
346
+ // 还没被回答的提问在这里补发:ready 已经发过,所以 App 拿得到 clientId,
347
+ // 补发的提问是可以被回答的。**送出去不清空**——App 可能又崩/又重启,
348
+ // 只要还没人回答,下次连上还得再给一遍(App 侧按 eventId 去重)。
349
+ if (record.eventsPending.size > 0) {
350
+ const pending = [...record.eventsPending.values()]
351
+ this.logger.info?.(
352
+ `mobile-link: replaying ${pending.length} unanswered question(s) to ${deviceId}`)
353
+ for (const value of pending) {
354
+ this.send(deviceId, { t: 'item', id: frame.id, value })
355
+ }
356
+ }
219
357
  return
220
358
  }
221
359
  const muxId = `s:${deviceId}:${frame.id}`
@@ -265,6 +403,8 @@ export class DeviceRouter {
265
403
  })
266
404
  return
267
405
  }
406
+ // 答过了就不再是"待答":删掉它,免得下次重连又把已经回答过的问题推给 App。
407
+ record.eventsPending.delete(eventId)
268
408
  // The clientId is always the one from *this device's* $events ready frame
269
409
  // (spec §4.1.5); any clientId the device echoed is ignored on purpose.
270
410
  if (!this.dedupe.claim(eventId)) return
@@ -302,6 +442,27 @@ export class DeviceRouter {
302
442
  forwardEvent(deviceId, value) {
303
443
  const record = this.devices.get(deviceId)
304
444
  if (!record) return
445
+ // 只有"待回答的提问"值得留:别的都是"某事刚发生"的通知,补发只会让 App
446
+ // 对着几分钟前的事件发通知(例如一条早就结束的 turn)。
447
+ // 待答表:不管手机在不在都记下来,直到**有人回答**或 host 作废它。
448
+ // 只记在线时的转发是不够的——App 收下提问后崩一次/重启一次,那条提问就再也没人
449
+ // 记得了(host 认为已经投给这条流了,不会重发)。连接器记着,重连时再给一遍。
450
+ if (nonEmptyString(value?.eventId)) {
451
+ if (value.type === 'cancel') record.eventsPending.delete(value.eventId)
452
+ else if (value.type === 'waterfall') {
453
+ record.eventsPending.set(value.eventId, value)
454
+ if (record.eventsPending.size > this.eventsBacklog) {
455
+ // 超上限时丢最老的(Map 保持插入顺序)。
456
+ const oldest = record.eventsPending.keys().next().value
457
+ record.eventsPending.delete(oldest)
458
+ }
459
+ }
460
+ }
461
+ if (record.offlineSince !== undefined && isBufferedEvent(value)) {
462
+ this.logger.debug?.(
463
+ `mobile-link: kept ${value.type} ${value.event ?? value.eventId ?? ''} for offline ${deviceId}`)
464
+ return
465
+ }
305
466
  if (value && value.type === 'ready' && nonEmptyString(value.clientId)) {
306
467
  record.clientId = value.clientId
307
468
  record.readyValue = value
@@ -371,7 +532,11 @@ export class DeviceRouter {
371
532
  deviceId: record.deviceId,
372
533
  name: record.name,
373
534
  model: record.model,
535
+ // Which build that phone reported on its last handshake.
536
+ client: record.client ?? null,
374
537
  connectedAt: record.connectedAt,
538
+ offlineSince: record.offlineSince ?? null,
539
+ unansweredEvents: record.eventsPending.size,
375
540
  eventsReady: Boolean(record.clientId),
376
541
  openStreams: record.dlpIds.size,
377
542
  }))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plugin-mobile-link",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
4
4
  "description": "Put your PC's DeepSeek Harness in your pocket: dials a relay over WSS so the native iOS client reaches this machine even without a public IP.",
5
5
  "keywords": [
6
6
  "dsh",