dsh-mobilecode 0.1.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/lib/index.js ADDED
@@ -0,0 +1,976 @@
1
+ /**
2
+ * dsh-mobilecode — host half. Runs in the dsh web GUI's server process.
3
+ *
4
+ * Hot-pluggable DSH plugin (same architecture as @windypro-rourou/dsh-logcat):
5
+ * - DevicePreviewEngine (lib/device-preview.js) owns preview servers
6
+ * (serve-sim / serve-avd), Metro bundlers, and build-install-launch runs.
7
+ * - HTTP routes under /api/dsh-mobilecode — the browser pane's read/write
8
+ * surface (the mobilecode `server.devicePreview` group, renamed so it
9
+ * cannot collide with other plugins).
10
+ * - Agent tools device_run + device_detect — the mobilecode
11
+ * `tool/device-run` contract, with an explicit `directory` parameter
12
+ * because DSH tools have no session-location concept.
13
+ * - A system-prompt guidance section telling the agent these tools exist.
14
+ *
15
+ * All routes are loopback-only (the pane lives in the same browser).
16
+ */
17
+
18
+ import { defineTool } from '@deepseek-ai/dsh-tools'
19
+ import * as DeviceBuild from './device-build.js'
20
+ import { DevicePreviewEngine } from './device-preview.js'
21
+ import * as Setup from './setup.js'
22
+
23
+ export const name = 'mobilecode'
24
+ export const inject = ['webServer', 'tools', 'systemPrompt']
25
+ export const provide = ['mobilecode']
26
+
27
+ const SECTION_ORDER = 150
28
+
29
+ const API_BASE = '/api/dsh-mobilecode'
30
+
31
+ const DEFAULT_TIMEOUT_MS = 10 * 60_000
32
+ const MAX_TIMEOUT_MS = 30 * 60_000
33
+ const POLL_MS = 2000
34
+ const LOG_TAIL = 60
35
+ const BUSY = ['building', 'installing', 'launching']
36
+
37
+ function isLoopbackRequest(req) {
38
+ const address = req.socket?.remoteAddress ?? ''
39
+ const host = req.headers?.host ?? ''
40
+ const okAddress = address === '::1' || address === '127.0.0.1' || address.startsWith('::ffff:127.') || address.startsWith('127.')
41
+ if (!okAddress) return false
42
+ let hostUrl
43
+ try { hostUrl = new URL('http://' + host) } catch { return false }
44
+ if (hostUrl.hostname !== 'localhost' && !hostUrl.hostname.startsWith('127.')) return false
45
+ return true
46
+ }
47
+
48
+ function writeJson(res, status, body) {
49
+ res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'referrer-policy': 'no-referrer' })
50
+ res.end(JSON.stringify(body))
51
+ }
52
+
53
+ async function readBody(req, res) {
54
+ const chunks = []
55
+ let size = 0
56
+ for await (const chunk of req) {
57
+ size += chunk.length
58
+ if (size > 64 * 1024) { writeJson(res, 413, { error: 'body too large' }); return undefined }
59
+ chunks.push(chunk)
60
+ }
61
+ try { return JSON.parse(Buffer.concat(chunks).toString('utf8')) } catch { return {} }
62
+ }
63
+
64
+ /** A directory the caller asked for, or the configured/working default. */
65
+ function resolveDirectory(body, config) {
66
+ if (typeof body?.directory === 'string' && body.directory !== '') return body.directory
67
+ if (typeof config?.defaultDirectory === 'string' && config.defaultDirectory !== '') return config.defaultDirectory
68
+ return process.cwd()
69
+ }
70
+
71
+ function makeRoutes(engine, config) {
72
+ const guard = (req, res) => {
73
+ if (!isLoopbackRequest(req)) { writeJson(res, 403, { error: 'forbidden: loopback-only' }); return false }
74
+ return true
75
+ }
76
+ const platformOf = (value) => (value === 'ios' || value === 'android' ? value : undefined)
77
+ const routes = [
78
+ // GET /api/dsh-mobilecode?directory=... → current info (platforms, servers, builds, bundler).
79
+ {
80
+ kind: 'exact',
81
+ path: API_BASE,
82
+ handler: async (req, res) => {
83
+ if (!guard(req, res)) return
84
+ if ((req.method ?? 'GET') !== 'GET') { writeJson(res, 405, { error: 'method not allowed' }); return }
85
+ const url = new URL(req.url ?? '/', 'http://localhost')
86
+ const directory = url.searchParams.get('directory') ?? resolveDirectory({}, config)
87
+ try {
88
+ writeJson(res, 200, await engine.info({ directory }))
89
+ } catch (error) {
90
+ writeJson(res, 500, { error: error instanceof Error ? error.message : String(error) })
91
+ }
92
+ },
93
+ },
94
+ // POST /api/dsh-mobilecode/start {directory?, platform} — start the preview server.
95
+ {
96
+ kind: 'exact',
97
+ path: API_BASE + '/start',
98
+ handler: async (req, res) => {
99
+ if (!guard(req, res)) return
100
+ if ((req.method ?? 'GET') !== 'POST') { writeJson(res, 405, { error: 'method not allowed' }); return }
101
+ const body = await readBody(req, res)
102
+ if (body === undefined) return
103
+ const platform = platformOf(body.platform)
104
+ if (!platform) { writeJson(res, 400, { error: 'platform is required (ios|android)' }); return }
105
+ try {
106
+ writeJson(res, 200, await engine.start({ directory: resolveDirectory(body, config), platform }))
107
+ } catch (error) {
108
+ writeJson(res, 500, { error: error instanceof Error ? error.message : String(error) })
109
+ }
110
+ },
111
+ },
112
+ // POST /api/dsh-mobilecode/stop {directory?, platform} — stop the preview server.
113
+ {
114
+ kind: 'exact',
115
+ path: API_BASE + '/stop',
116
+ handler: async (req, res) => {
117
+ if (!guard(req, res)) return
118
+ if ((req.method ?? 'GET') !== 'POST') { writeJson(res, 405, { error: 'method not allowed' }); return }
119
+ const body = await readBody(req, res)
120
+ if (body === undefined) return
121
+ const platform = platformOf(body.platform)
122
+ if (!platform) { writeJson(res, 400, { error: 'platform is required (ios|android)' }); return }
123
+ try {
124
+ writeJson(res, 200, await engine.stop({ directory: resolveDirectory(body, config), platform }))
125
+ } catch (error) {
126
+ writeJson(res, 500, { error: error instanceof Error ? error.message : String(error) })
127
+ }
128
+ },
129
+ },
130
+ // POST /api/dsh-mobilecode/run {directory?, platform, relaunch?} — build, install, launch.
131
+ {
132
+ kind: 'exact',
133
+ path: API_BASE + '/run',
134
+ handler: async (req, res) => {
135
+ if (!guard(req, res)) return
136
+ if ((req.method ?? 'GET') !== 'POST') { writeJson(res, 405, { error: 'method not allowed' }); return }
137
+ const body = await readBody(req, res)
138
+ if (body === undefined) return
139
+ const platform = platformOf(body.platform)
140
+ if (!platform) { writeJson(res, 400, { error: 'platform is required (ios|android)' }); return }
141
+ try {
142
+ const directory = resolveDirectory(body, config)
143
+ const info = await engine.runApp({ directory, platform, relaunch: body.relaunch === true })
144
+ writeJson(res, 200, info)
145
+ } catch (error) {
146
+ writeJson(res, 500, { error: error instanceof Error ? error.message : String(error) })
147
+ }
148
+ },
149
+ },
150
+ // POST /api/dsh-mobilecode/run/stop {directory?, platform} — cancel the build or quit the app.
151
+ {
152
+ kind: 'exact',
153
+ path: API_BASE + '/run/stop',
154
+ handler: async (req, res) => {
155
+ if (!guard(req, res)) return
156
+ if ((req.method ?? 'GET') !== 'POST') { writeJson(res, 405, { error: 'method not allowed' }); return }
157
+ const body = await readBody(req, res)
158
+ if (body === undefined) return
159
+ const platform = platformOf(body.platform)
160
+ if (!platform) { writeJson(res, 400, { error: 'platform is required (ios|android)' }); return }
161
+ try {
162
+ writeJson(res, 200, await engine.stopApp({ directory: resolveDirectory(body, config), platform }))
163
+ } catch (error) {
164
+ writeJson(res, 500, { error: error instanceof Error ? error.message : String(error) })
165
+ }
166
+ },
167
+ },
168
+ // POST /api/dsh-mobilecode/focus {directory?} — user switched to this directory: park others.
169
+ {
170
+ kind: 'exact',
171
+ path: API_BASE + '/focus',
172
+ handler: async (req, res) => {
173
+ if (!guard(req, res)) return
174
+ if ((req.method ?? 'GET') !== 'POST') { writeJson(res, 405, { error: 'method not allowed' }); return }
175
+ const body = await readBody(req, res)
176
+ if (body === undefined) return
177
+ try {
178
+ writeJson(res, 200, await engine.focus({ directory: resolveDirectory(body, config) }))
179
+ } catch (error) {
180
+ writeJson(res, 500, { error: error instanceof Error ? error.message : String(error) })
181
+ }
182
+ },
183
+ },
184
+ // GET /api/dsh-mobilecode/welcome → { show, prompt } — first-run flag + the copy-paste AI prompt.
185
+ {
186
+ kind: 'exact',
187
+ path: API_BASE + '/welcome',
188
+ handler: async (req, res) => {
189
+ if (!guard(req, res)) return
190
+ if ((req.method ?? 'GET') !== 'GET') { writeJson(res, 405, { error: 'method not allowed' }); return }
191
+ const settings = Setup.readSettings()
192
+ writeJson(res, 200, { show: settings.welcomeDismissed !== true, prompt: Setup.WELCOME_PROMPT })
193
+ },
194
+ },
195
+ // POST /api/dsh-mobilecode/welcome/dismiss — never show the welcome window again.
196
+ {
197
+ kind: 'exact',
198
+ path: API_BASE + '/welcome/dismiss',
199
+ handler: async (req, res) => {
200
+ if (!guard(req, res)) return
201
+ if ((req.method ?? 'GET') !== 'POST') { writeJson(res, 405, { error: 'method not allowed' }); return }
202
+ await readBody(req, res)
203
+ Setup.writeSettings({ welcomeDismissed: true })
204
+ writeJson(res, 200, { ok: true })
205
+ },
206
+ },
207
+ // GET /api/dsh-mobilecode/doctor → [{name, ok, detail, fix?}] — plugin health check.
208
+ {
209
+ kind: 'exact',
210
+ path: API_BASE + '/doctor',
211
+ handler: async (req, res) => {
212
+ if (!guard(req, res)) return
213
+ if ((req.method ?? 'GET') !== 'GET') { writeJson(res, 405, { error: 'method not allowed' }); return }
214
+ try {
215
+ const checks = await Setup.runDoctor()
216
+ writeJson(res, 200, { checks })
217
+ } catch (error) {
218
+ writeJson(res, 500, { error: error instanceof Error ? error.message : String(error) })
219
+ }
220
+ },
221
+ },
222
+ // POST /api/dsh-mobilecode/doctor/fix {id} — auto-fix one failing check (e.g. paddleocr).
223
+ {
224
+ kind: 'exact',
225
+ path: API_BASE + '/doctor/fix',
226
+ handler: async (req, res) => {
227
+ if (!guard(req, res)) return
228
+ if ((req.method ?? 'GET') !== 'POST') { writeJson(res, 405, { error: 'method not allowed' }); return }
229
+ const body = await readBody(req, res)
230
+ if (body === undefined) return
231
+ if (typeof body.id !== 'string' || body.id === '') { writeJson(res, 400, { error: 'id is required' }); return }
232
+ try {
233
+ writeJson(res, 200, await Setup.runFix(body.id))
234
+ } catch (error) {
235
+ writeJson(res, 500, { error: error instanceof Error ? error.message : String(error) })
236
+ }
237
+ },
238
+ },
239
+ // GET /api/dsh-mobilecode/ocr → {installed, working, state, log} — PaddleOCR install state.
240
+ {
241
+ kind: 'exact',
242
+ path: API_BASE + '/ocr',
243
+ handler: async (req, res) => {
244
+ if (!guard(req, res)) return
245
+ if ((req.method ?? 'GET') !== 'GET') { writeJson(res, 405, { error: 'method not allowed' }); return }
246
+ try {
247
+ writeJson(res, 200, await Setup.ocrStatus())
248
+ } catch (error) {
249
+ writeJson(res, 500, { error: error instanceof Error ? error.message : String(error) })
250
+ }
251
+ },
252
+ },
253
+ // POST /api/dsh-mobilecode/ocr/install — kick off the one-click PaddleOCR install.
254
+ {
255
+ kind: 'exact',
256
+ path: API_BASE + '/ocr/install',
257
+ handler: async (req, res) => {
258
+ if (!guard(req, res)) return
259
+ if ((req.method ?? 'GET') !== 'POST') { writeJson(res, 405, { error: 'method not allowed' }); return }
260
+ await readBody(req, res)
261
+ try {
262
+ writeJson(res, 200, Setup.startOcrInstall())
263
+ } catch (error) {
264
+ writeJson(res, 500, { error: error instanceof Error ? error.message : String(error) })
265
+ }
266
+ },
267
+ },
268
+ // GET/POST /api/dsh-mobilecode/settings — persisted plugin settings.
269
+ {
270
+ kind: 'exact',
271
+ path: API_BASE + '/settings',
272
+ handler: async (req, res) => {
273
+ if (!guard(req, res)) return
274
+ const method = req.method ?? 'GET'
275
+ if (method === 'GET') {
276
+ writeJson(res, 200, Setup.readSettings())
277
+ return
278
+ }
279
+ if (method === 'POST') {
280
+ const body = await readBody(req, res)
281
+ if (body === undefined) return
282
+ const allowed = ['defaultDirectory']
283
+ const patch = {}
284
+ for (const key of allowed) if (typeof body?.[key] === 'string' && body[key] !== '') patch[key] = body[key]
285
+ writeJson(res, 200, Setup.writeSettings(patch))
286
+ return
287
+ }
288
+ writeJson(res, 405, { error: 'method not allowed' })
289
+ },
290
+ },
291
+ ]
292
+ return routes
293
+ }
294
+
295
+ // ── agent tools ───────────────────────────────────────────────────────────────
296
+
297
+ const label = (platform) => (platform === 'ios' ? 'iOS' : 'Android')
298
+
299
+ /** The mobilecode BuildSummary → the strict DSH output schema. */
300
+ function summarize(info, targets, complete = true) {
301
+ const builds = targets.flatMap((platform) => {
302
+ const build = info.builds.find((item) => item.platform === platform)
303
+ if (!build) return []
304
+ const failed = build.status === 'failed'
305
+ const out = {
306
+ platform,
307
+ status: build.status,
308
+ log: failed || !complete ? build.log.slice(-LOG_TAIL) : [],
309
+ }
310
+ if (build.step !== undefined) out.step = build.step
311
+ if (build.target !== undefined) out.target = build.target
312
+ if (build.appID !== undefined) out.appID = build.appID
313
+ if (build.error !== undefined) out.error = build.error
314
+ return [out]
315
+ })
316
+ const out = { platforms: info.platforms, builds, complete }
317
+ if (info.framework !== undefined) out.framework = info.framework
318
+ if (info.bundler) {
319
+ out.bundler = {
320
+ status: info.bundler.status,
321
+ log: info.bundler.status === 'exited' || !complete ? info.bundler.log.slice(-LOG_TAIL) : [],
322
+ }
323
+ if (info.bundler.url !== undefined) out.bundler.url = info.bundler.url
324
+ }
325
+ return out
326
+ }
327
+
328
+ function toModelOutput(output) {
329
+ const lines = []
330
+ if (output.framework) lines.push(`Framework: ${output.framework}`)
331
+ if (output.builds.length === 0) lines.push('No app has been run yet.')
332
+ for (const build of output.builds) {
333
+ const detail = [build.appID, build.target && `target ${build.target}`].filter(Boolean).join(', ')
334
+ const head = `${label(build.platform)}: ${build.status}${build.step ? ` (${build.step})` : ''}${detail ? ` — ${detail}` : ''}`
335
+ lines.push(build.error ? `${head}\n ${build.error}` : head)
336
+ if (build.log.length > 0) lines.push(build.log.map((line) => ` | ${line}`).join('\n'))
337
+ }
338
+ if (output.bundler) {
339
+ lines.push(`Metro: ${output.bundler.status}${output.bundler.url ? ` at ${output.bundler.url}` : ''}`)
340
+ if (output.bundler.log.length > 0) lines.push(output.bundler.log.map((line) => ` | ${line}`).join('\n'))
341
+ }
342
+ if (!output.complete)
343
+ lines.push('Timed out waiting for the run to finish; it is still in progress. Call again with action "status" to check on it.')
344
+ return lines.join('\n')
345
+ }
346
+
347
+ function deviceRunTool(engine, config) {
348
+ return defineTool({
349
+ name: 'device_run',
350
+ description: 'Build, install and launch the mobile app in this project on the iOS Simulator and Android Emulator, ' +
351
+ 'the same way the Play button in the device pane does, and wait for the result. ' +
352
+ 'Works for Expo (prebuild runs automatically), React Native (Metro is started for you) and plain native projects. ' +
353
+ 'Use it after creating or changing a mobile app to check that it builds and launches; when a build fails, ' +
354
+ 'the reported error and log tail say why, so fix that and run again. The user sees the app running in the device pane.',
355
+ parameters: {
356
+ action: {
357
+ type: 'string',
358
+ enum: ['run', 'stop', 'status'],
359
+ description: 'run: build, install and launch the app, then wait for the result. ' +
360
+ 'stop: terminate the running app or cancel its build. status: report the current state without changing anything.',
361
+ },
362
+ platform: {
363
+ type: 'string',
364
+ enum: ['ios', 'android', 'all'],
365
+ description: 'Which device to target. Defaults to all detected platforms.',
366
+ },
367
+ directory: {
368
+ type: 'string',
369
+ description: 'Absolute path to the mobile project to operate on. Required when the working directory is not the project root.',
370
+ },
371
+ timeout: {
372
+ type: 'integer',
373
+ description: `How long to wait for a run to finish, in milliseconds (default: ${DEFAULT_TIMEOUT_MS}; maximum: ${MAX_TIMEOUT_MS}).`,
374
+ },
375
+ },
376
+ output: {
377
+ schema: {
378
+ type: 'object',
379
+ additionalProperties: false,
380
+ properties: {
381
+ framework: { type: 'string', enum: ['expo', 'react-native', 'native'] },
382
+ platforms: {
383
+ type: 'array',
384
+ required: true,
385
+ items: { type: 'string', enum: ['ios', 'android'] },
386
+ },
387
+ builds: {
388
+ type: 'array',
389
+ required: true,
390
+ items: {
391
+ type: 'object',
392
+ additionalProperties: false,
393
+ properties: {
394
+ platform: { type: 'string', enum: ['ios', 'android'], required: true },
395
+ status: { type: 'string', enum: ['idle', 'building', 'installing', 'launching', 'running', 'failed'], required: true },
396
+ step: { type: 'string' },
397
+ target: { type: 'string' },
398
+ appID: { type: 'string' },
399
+ error: { type: 'string' },
400
+ log: { type: 'array', required: true, items: { type: 'string' } },
401
+ },
402
+ },
403
+ },
404
+ bundler: {
405
+ type: 'object',
406
+ additionalProperties: false,
407
+ properties: {
408
+ status: { type: 'string', enum: ['starting', 'running', 'exited'], required: true },
409
+ url: { type: 'string' },
410
+ log: { type: 'array', required: true, items: { type: 'string' } },
411
+ },
412
+ },
413
+ complete: { type: 'boolean', required: true },
414
+ },
415
+ },
416
+ render: (_args, value) => [{ type: 'text', text: toModelOutput(value ?? { platforms: [], builds: [], complete: true }) }],
417
+ },
418
+ async execute(args) {
419
+ const wanted = args.platform ?? 'all'
420
+ const directory = typeof args.directory === 'string' && args.directory !== '' ? args.directory : resolveDirectory({}, config)
421
+ const info = await engine.info({ directory })
422
+ const targets = wanted === 'all' ? info.platforms : [wanted]
423
+ if (info.platforms.length === 0) {
424
+ throw new Error('No iOS or Android project was found at or below this directory.')
425
+ }
426
+ const missing = targets.filter((platform) => !info.platforms.includes(platform))
427
+ if (missing.length > 0) {
428
+ throw new Error(`No ${label(missing[0])} project was found. Detected: ${info.platforms.map(label).join(', ')}.`)
429
+ }
430
+
431
+ if (args.action === 'status') return summarize(info, targets)
432
+
433
+ if (args.action === 'stop') {
434
+ let latest = info
435
+ for (const platform of targets) latest = await engine.stopApp({ directory, platform })
436
+ return summarize(latest, targets)
437
+ }
438
+
439
+ let latest = info
440
+ for (const platform of targets) latest = await engine.runApp({ directory, platform })
441
+ const timeout = Math.min(Math.max(args.timeout ?? DEFAULT_TIMEOUT_MS, 0), MAX_TIMEOUT_MS)
442
+ const deadline = Date.now() + timeout
443
+ const busy = (current) =>
444
+ targets.some((platform) => {
445
+ const build = current.builds.find((item) => item.platform === platform)
446
+ return !!build && BUSY.includes(build.status)
447
+ })
448
+ while (busy(latest)) {
449
+ if (Date.now() >= deadline) return summarize(latest, targets, false)
450
+ await new Promise((resolve) => setTimeout(resolve, POLL_MS))
451
+ latest = await engine.info({ directory })
452
+ }
453
+ return summarize(latest, targets)
454
+ },
455
+ })
456
+ }
457
+
458
+ function deviceDetectTool(engine, config) {
459
+ return defineTool({
460
+ name: 'device_detect',
461
+ description: 'Detect the mobile platforms (iOS Simulator / Android Emulator) in a directory and report what is ' +
462
+ 'attached: which platforms the project supports, its framework, and the first attached Android device. ' +
463
+ 'Call this before device_run to learn whether the project has an iOS and/or Android target and what to expect.',
464
+ parameters: {
465
+ directory: {
466
+ type: 'string',
467
+ description: 'Absolute path to the mobile project to inspect. Required when the working directory is not the project root.',
468
+ },
469
+ },
470
+ output: {
471
+ schema: {
472
+ type: 'object',
473
+ additionalProperties: false,
474
+ properties: {
475
+ directory: { type: 'string', required: true },
476
+ platforms: {
477
+ type: 'array',
478
+ required: true,
479
+ items: { type: 'string', enum: ['ios', 'android'] },
480
+ },
481
+ framework: { type: 'string', enum: ['expo', 'react-native', 'native'] },
482
+ device: { type: 'string' },
483
+ },
484
+ },
485
+ render: (_args, value) => {
486
+ const v = value ?? { directory: '', platforms: [] }
487
+ const lines = [`Directory: ${v.directory}`, `Platforms: ${v.platforms.length > 0 ? v.platforms.map(label).join(', ') : '(none detected)'}`]
488
+ if (v.framework) lines.push(`Framework: ${v.framework}`)
489
+ if (v.device) lines.push(`Android device: ${v.device}`)
490
+ return [{ type: 'text', text: lines.join('\n') }]
491
+ },
492
+ },
493
+ async execute(args) {
494
+ const directory = typeof args.directory === 'string' && args.directory !== '' ? args.directory : resolveDirectory({}, config)
495
+ const projects = DeviceBuild.findProjects(directory)
496
+ const info = await engine.info({ directory })
497
+ let device
498
+ try { device = await DeviceBuild.androidDevice() } catch { /* no adb */ }
499
+ const out = {
500
+ directory,
501
+ platforms: projects.map((project) => project.platform),
502
+ framework: info.framework,
503
+ }
504
+ if (device) out.device = device
505
+ return out
506
+ },
507
+ })
508
+ }
509
+
510
+ /** First attached (or explicit) Android device serial; throws a friendly error when none. */
511
+ async function requireAndroidDevice(serial) {
512
+ const target = serial && serial !== '' ? serial : await DeviceBuild.androidDevice()
513
+ if (!target) throw new Error('No Android device is attached. Boot one (device_run on an Android project does it automatically), or pass a serial.')
514
+ return target
515
+ }
516
+
517
+ /** Common key names → Android keycode. Anything else can be passed as a raw keycode integer. */
518
+ const KEYCODES = {
519
+ back: 4, home: 3, menu: 82, recents: 187, app_switch: 187, enter: 66, tab: 61, space: 62,
520
+ delete: 67, backspace: 67, escape: 111, search: 84, camera: 27, power: 26, volume_up: 24,
521
+ volume_down: 25, dpad_up: 19, dpad_down: 20, dpad_left: 21, dpad_right: 22, wakeup: 224,
522
+ sleep: 223, dial: 5, endcall: 6, clear: 28,
523
+ }
524
+
525
+ function deviceInputTool() {
526
+ return defineTool({
527
+ name: 'device_input',
528
+ description: 'Send input to an attached Android device: tap at pixel coordinates (from device_screen), swipe, type text, ' +
529
+ 'or press a hardware key. Coordinates are ABSOLUTE physical pixels — the same space device_screen returns, so take ' +
530
+ 'the center of an OCR/UI box ((x1+x2)/2, (y1+y2)/2). Works for native, Compose, React Native and Expo apps.',
531
+ parameters: {
532
+ serial: {
533
+ type: 'string',
534
+ description: 'Android device serial. Omit to use the first attached device.',
535
+ },
536
+ action: {
537
+ type: 'string',
538
+ enum: ['tap', 'swipe', 'text', 'key'],
539
+ description: 'What to send: tap (x,y), swipe (x1,y1 → x2,y2, optional duration ms), text (ASCII, spaces ok), or key (named key or raw keycode).',
540
+ },
541
+ x: { type: 'integer', description: 'Pixel X for tap, or swipe start X.' },
542
+ y: { type: 'integer', description: 'Pixel Y for tap, or swipe start Y.' },
543
+ x2: { type: 'integer', description: 'Swipe end X (action=swipe only).' },
544
+ y2: { type: 'integer', description: 'Swipe end Y (action=swipe only).' },
545
+ duration: { type: 'integer', description: 'Swipe duration in ms (default 200).' },
546
+ text: { type: 'string', description: 'Text to type (action=text; ASCII only, spaces supported).' },
547
+ key: { type: 'string', description: 'Key name (back, home, enter, tab, delete, volume_up, …) or a raw keycode integer (action=key).' },
548
+ },
549
+ output: {
550
+ schema: {
551
+ type: 'object',
552
+ additionalProperties: false,
553
+ properties: {
554
+ serial: { type: 'string', required: true },
555
+ action: { type: 'string', required: true },
556
+ sent: { type: 'string', required: true },
557
+ },
558
+ },
559
+ render: (_args, value) => {
560
+ const v = value ?? { serial: '', action: '', sent: '' }
561
+ return [{ type: 'text', text: `Sent ${v.action} (${v.sent}) to ${v.serial}` }]
562
+ },
563
+ },
564
+ async execute(args) {
565
+ const serial = await requireAndroidDevice(args.serial)
566
+ const action = args.action ?? 'tap'
567
+ const adbArgs = (shell) => ['-s', serial, 'shell', ...shell]
568
+ switch (action) {
569
+ case 'tap': {
570
+ if (typeof args.x !== 'number' || typeof args.y !== 'number') throw new Error('action=tap requires x and y (integer pixels).')
571
+ await DeviceBuild.exec(DeviceBuild.adb(), adbArgs(['input', 'tap', String(args.x), String(args.y)])).exit
572
+ return { serial, action, sent: `tap ${args.x},${args.y}` }
573
+ }
574
+ case 'swipe': {
575
+ if (typeof args.x !== 'number' || typeof args.y !== 'number' || typeof args.x2 !== 'number' || typeof args.y2 !== 'number') {
576
+ throw new Error('action=swipe requires x, y, x2, y2.')
577
+ }
578
+ const duration = args.duration ?? 200
579
+ await DeviceBuild.exec(DeviceBuild.adb(), adbArgs(['input', 'swipe', String(args.x), String(args.y), String(args.x2), String(args.y2), String(duration)])).exit
580
+ return { serial, action, sent: `swipe ${args.x},${args.y}→${args.x2},${args.y2} (${duration}ms)` }
581
+ }
582
+ case 'text': {
583
+ if (typeof args.text !== 'string' || args.text.length === 0) throw new Error('action=text requires a non-empty text string.')
584
+ const escaped = args.text.replace(/\s/g, '%s')
585
+ await DeviceBuild.exec(DeviceBuild.adb(), adbArgs(['input', 'text', escaped])).exit
586
+ return { serial, action, sent: `text "${args.text}"` }
587
+ }
588
+ case 'key': {
589
+ const raw = String(args.key ?? '')
590
+ const code = /^\d+$/.test(raw) ? Number(raw) : KEYCODES[raw.toLowerCase()]
591
+ if (!code) throw new Error(`unknown key "${raw}" — use a name from ${Object.keys(KEYCODES).join(', ')} or a raw keycode integer.`)
592
+ await DeviceBuild.exec(DeviceBuild.adb(), adbArgs(['input', 'keyevent', String(code)])).exit
593
+ return { serial, action, sent: `key ${raw} (${code})` }
594
+ }
595
+ default:
596
+ throw new Error(`unknown action "${action}" — use tap, swipe, text or key.`)
597
+ }
598
+ },
599
+ })
600
+ }
601
+
602
+ function deviceScreenTool(engine) {
603
+ return defineTool({
604
+ name: 'device_screen',
605
+ description: 'See what is on an attached Android device right now: captures the screen as a PNG file, dumps the UI ' +
606
+ 'hierarchy (uiautomator) with pixel bounds, and OCRs the pixels with local PaddleOCR (text + coordinates). ' +
607
+ 'Use this after device_run to confirm the app rendered, to read what the app shows, and to drive UI flows by ' +
608
+ 'tapping the returned coordinates with device_input. Works for native, Compose, React Native and Expo apps; ' +
609
+ 'OCR also covers WebViews, games and other surfaces that expose no accessibility labels.',
610
+ parameters: {
611
+ serial: {
612
+ type: 'string',
613
+ description: 'Android device serial. Omit to use the first attached device.',
614
+ },
615
+ ocr: {
616
+ type: 'boolean',
617
+ description: 'Whether to run PaddleOCR over the screenshot (default true). Set false when OCR is not needed — it can take a few seconds.',
618
+ },
619
+ directory: {
620
+ type: 'string',
621
+ description: 'Where to store the screenshot PNG (default: a temp directory). The path is returned so a multimodal model can read it.',
622
+ },
623
+ },
624
+ output: {
625
+ schema: {
626
+ type: 'object',
627
+ additionalProperties: false,
628
+ properties: {
629
+ serial: { type: 'string', required: true },
630
+ screenshot: { type: 'string' },
631
+ width: { type: 'integer' },
632
+ height: { type: 'integer' },
633
+ foreground: { type: 'string' },
634
+ ui: {
635
+ type: 'array',
636
+ items: {
637
+ type: 'object',
638
+ additionalProperties: false,
639
+ properties: {
640
+ text: { type: 'string', required: true },
641
+ resourceId: { type: 'string' },
642
+ bounds: { type: 'array', items: { type: 'integer' } },
643
+ },
644
+ },
645
+ },
646
+ ocr: {
647
+ type: 'array',
648
+ items: {
649
+ type: 'object',
650
+ additionalProperties: false,
651
+ properties: {
652
+ text: { type: 'string', required: true },
653
+ confidence: { type: 'number' },
654
+ box: { type: 'array', items: { type: 'integer' } },
655
+ },
656
+ },
657
+ },
658
+ ocrError: { type: 'string' },
659
+ },
660
+ },
661
+ render: (_args, value) => {
662
+ const v = value ?? { serial: '' }
663
+ const lines = [`Device: ${v.serial}${v.foreground ? ` — foreground: ${v.foreground}` : ''}`]
664
+ if (v.screenshot) lines.push(`Screenshot: ${v.screenshot}${v.width ? ` (${v.width}x${v.height})` : ''}`)
665
+ if (v.ocrError) lines.push(`OCR unavailable: ${v.ocrError}`)
666
+ if (v.ui && v.ui.length > 0) {
667
+ lines.push('UI hierarchy:')
668
+ for (const item of v.ui.slice(0, 40)) {
669
+ const where = item.bounds ? ` @${item.bounds.join(',')}` : ''
670
+ lines.push(` - ${item.text}${item.resourceId ? ` [${item.resourceId}]` : ''}${where}`)
671
+ }
672
+ if (v.ui.length > 40) lines.push(` … and ${v.ui.length - 40} more`)
673
+ }
674
+ if (v.ocr && v.ocr.length > 0) {
675
+ lines.push('OCR (local PaddleOCR):')
676
+ for (const item of v.ocr.slice(0, 40)) {
677
+ lines.push(` - "${item.text}" (${Math.round(item.confidence * 100)}%) box=${item.box.join(',')}`)
678
+ }
679
+ if (v.ocr.length > 40) lines.push(` … and ${v.ocr.length - 40} more`)
680
+ }
681
+ if (!v.ui?.length && !v.ocr?.length) lines.push('No text found on screen.')
682
+ return [{ type: 'text', text: lines.join('\n') }]
683
+ },
684
+ },
685
+ async execute(args) {
686
+ const serial = await requireAndroidDevice(args.serial)
687
+ const png = await DeviceBuild.screenCapture(serial, args.directory)
688
+ const [ui, foreground, size] = await Promise.all([
689
+ DeviceBuild.uiDump(serial).catch(() => []),
690
+ DeviceBuild.foregroundActivity(serial).catch(() => undefined),
691
+ captureScreenSize(serial),
692
+ ])
693
+ const out = { serial, ui, ...(foreground ? { foreground } : {}) }
694
+ if (png) {
695
+ out.screenshot = png
696
+ if (size) { out.width = size.width; out.height = size.height }
697
+ }
698
+ if (args.ocr !== false) {
699
+ if (!png) {
700
+ out.ocrError = 'screenshot failed'
701
+ } else if (!DeviceBuild.ocrPython()) {
702
+ out.ocrError = 'PaddleOCR venv not found (install with: py -3.12 -m venv ~/.dsh/mobilecode/ocr-venv; pip install paddleocr==3.7.0 paddlepaddle==3.3.1 numpy<2)'
703
+ } else {
704
+ const ocr = await DeviceBuild.ocrImage(png).catch(() => [])
705
+ if (ocr.length > 0) out.ocr = ocr
706
+ else out.ocrError = 'PaddleOCR returned no text (or failed silently)'
707
+ }
708
+ }
709
+ return out
710
+ },
711
+ })
712
+ }
713
+
714
+ async function captureScreenSize(serial) {
715
+ const output = await DeviceBuild.capture(DeviceBuild.adb(), ['-s', serial, 'shell', 'wm', 'size'])
716
+ const match = /Physical size:\s*(\d+)x(\d+)/.exec(output)
717
+ return match ? { width: Number(match[1]), height: Number(match[2]) } : undefined
718
+ }
719
+
720
+ function deviceLogTool(engine) {
721
+ return defineTool({
722
+ name: 'device_log',
723
+ description: 'Read logs from an attached Android device: logcat (main buffer, or crash/events/kernel), optionally ' +
724
+ 'filtered to an app package, plus the kernel dmesg. Call it when a run fails or an app misbehaves to see crashes, ' +
725
+ 'exceptions and system messages. The crash buffer holds the last fatal exceptions; kernel (dmesg) needs adb root ' +
726
+ '(available on emulators).',
727
+ parameters: {
728
+ serial: {
729
+ type: 'string',
730
+ description: 'Android device serial. Omit to use the first attached device.',
731
+ },
732
+ buffer: {
733
+ type: 'string',
734
+ enum: ['main', 'crash', 'events', 'kernel', 'all'],
735
+ description: 'Which log buffer to read: main (default), crash (fatal exceptions/ANRs), events (activity lifecycle), kernel (dmesg), or all.',
736
+ },
737
+ filter: {
738
+ type: 'string',
739
+ description: 'Case-insensitive substring to keep only matching lines, e.g. an app package or an error tag.',
740
+ },
741
+ lines: {
742
+ type: 'integer',
743
+ description: 'How many log lines to read (default 200, maximum 2000).',
744
+ },
745
+ },
746
+ output: {
747
+ schema: {
748
+ type: 'object',
749
+ additionalProperties: false,
750
+ properties: {
751
+ serial: { type: 'string', required: true },
752
+ buffer: { type: 'string', required: true },
753
+ lines: { type: 'array', required: true, items: { type: 'string' } },
754
+ truncated: { type: 'boolean' },
755
+ },
756
+ },
757
+ render: (_args, value) => {
758
+ const v = value ?? { serial: '', buffer: 'main', lines: [] }
759
+ const head = `Device ${v.serial} — ${v.buffer} buffer${v.lines.length > 0 ? ` (${v.lines.length} lines)` : ' (empty)'}`
760
+ return [{ type: 'text', text: v.lines.length > 0 ? `${head}\n${v.lines.join('\n')}` : head }]
761
+ },
762
+ },
763
+ async execute(args) {
764
+ const serial = await requireAndroidDevice(args.serial)
765
+ const buffer = args.buffer ?? 'main'
766
+ const lines = Math.min(Math.max(args.lines ?? 200, 1), 2000)
767
+ let output
768
+ if (buffer === 'kernel') {
769
+ output = await DeviceBuild.dmesg(serial).catch(() => '')
770
+ } else {
771
+ output = await DeviceBuild.logcat(serial, { buffer, lines, filter: args.filter })
772
+ }
773
+ const all = output.split('\n').filter((line) => line !== '')
774
+ const list = buffer === 'kernel' || !args.filter ? all : all.filter((line) => line.toLowerCase().includes(String(args.filter).toLowerCase()))
775
+ return { serial, buffer, lines: list.slice(-lines), ...(all.length > lines ? { truncated: true } : {}) }
776
+ },
777
+ })
778
+ }
779
+
780
+ function deviceStatusTool(engine) {
781
+ return defineTool({
782
+ name: 'device_status',
783
+ description: 'One normalized snapshot of the mobile environment: attached Android devices, configured AVDs, whether an ' +
784
+ 'emulator binary exists, and what this plugin currently runs (preview servers, Metro bundlers, builds per directory ' +
785
+ 'with status/app/activity). Call this when you need to know what is attached and what is running before acting.',
786
+ parameters: {},
787
+ output: {
788
+ schema: {
789
+ type: 'object',
790
+ additionalProperties: false,
791
+ properties: {
792
+ devices: {
793
+ type: 'array',
794
+ items: {
795
+ type: 'object',
796
+ additionalProperties: false,
797
+ properties: {
798
+ serial: { type: 'string', required: true },
799
+ state: { type: 'string', required: true },
800
+ },
801
+ },
802
+ },
803
+ avds: { type: 'array', items: { type: 'string' } },
804
+ emulator: { type: 'string' },
805
+ runs: {
806
+ type: 'array',
807
+ items: {
808
+ type: 'object',
809
+ additionalProperties: false,
810
+ properties: {
811
+ directory: { type: 'string', required: true },
812
+ platform: { type: 'string', enum: ['ios', 'android'], required: true },
813
+ status: { type: 'string', required: true },
814
+ appID: { type: 'string' },
815
+ step: { type: 'string' },
816
+ },
817
+ },
818
+ },
819
+ metro: {
820
+ type: 'object',
821
+ additionalProperties: false,
822
+ properties: {
823
+ directory: { type: 'string' },
824
+ status: { type: 'string' },
825
+ url: { type: 'string' },
826
+ },
827
+ },
828
+ servers: {
829
+ type: 'array',
830
+ items: {
831
+ type: 'object',
832
+ additionalProperties: false,
833
+ properties: {
834
+ platform: { type: 'string', enum: ['ios', 'android'], required: true },
835
+ status: { type: 'string', required: true },
836
+ url: { type: 'string' },
837
+ },
838
+ },
839
+ },
840
+ },
841
+ },
842
+ render: (_args, value) => {
843
+ const v = value ?? {}
844
+ const lines = ['Devices:']
845
+ if (!v.devices || v.devices.length === 0) lines.push(' (none attached)')
846
+ for (const device of v.devices ?? []) lines.push(` - ${device.serial} [${device.state}]`)
847
+ lines.push(`AVDs: ${v.avds && v.avds.length > 0 ? v.avds.join(', ') : '(none)'}${v.emulator ? ` (emulator: ${v.emulator})` : ''}`)
848
+ lines.push('Runs:')
849
+ if (!v.runs || v.runs.length === 0) lines.push(' (nothing running)')
850
+ for (const run of v.runs ?? []) lines.push(` - ${run.directory} ${run.platform}: ${run.status}${run.step ? ` (${run.step})` : ''}${run.appID ? ` — ${run.appID}` : ''}`)
851
+ if (v.metro) lines.push(`Metro: ${v.metro.status}${v.metro.url ? ` at ${v.metro.url}` : ''}${v.metro.directory ? ` (${v.metro.directory})` : ''}`)
852
+ if (v.servers && v.servers.length > 0) {
853
+ lines.push('Preview servers:')
854
+ for (const server of v.servers) lines.push(` - ${server.platform}: ${server.status}${server.url ? ` ${server.url}` : ''}`)
855
+ }
856
+ return [{ type: 'text', text: lines.join('\n') }]
857
+ },
858
+ },
859
+ async execute() {
860
+ const info = await engine.info({ directory: resolveDirectory({}, {}) }).catch(() => undefined)
861
+ const [devices, avds] = await Promise.all([
862
+ DeviceBuild.devices().catch(() => []),
863
+ DeviceBuild.androidAvds().catch(() => []),
864
+ ])
865
+ const emulator = DeviceBuild.emulatorBinary()
866
+ const out = { devices, avds, ...(emulator ? { emulator } : {}) }
867
+ if (info) {
868
+ const runs = []
869
+ for (const build of info.builds ?? []) {
870
+ if (build.status === 'idle' || build.status === 'failed') continue
871
+ const run = { directory: build.directory ?? '', platform: build.platform, status: build.status }
872
+ if (build.appID) run.appID = build.appID
873
+ if (build.step) run.step = build.step
874
+ runs.push(run)
875
+ }
876
+ if (runs.length > 0) out.runs = runs
877
+ if (info.bundler && info.bundler.status !== 'exited') {
878
+ out.metro = { status: info.bundler.status }
879
+ if (info.bundler.url) out.metro.url = info.bundler.url
880
+ if (info.bundler.directory) out.metro.directory = info.bundler.directory
881
+ }
882
+ if (info.servers && info.servers.length > 0) out.servers = info.servers
883
+ }
884
+ return out
885
+ },
886
+ })
887
+ }
888
+
889
+ /** The system-prompt guidance section: what the agent can do and when to do it. */
890
+ function guidance() {
891
+ return [
892
+ 'The dsh-mobilecode plugin is available: it can detect mobile projects, run preview servers (iOS Simulator / Android Emulator),',
893
+ 'and build-install-launch apps the way the device pane\'s Play button does.',
894
+ '',
895
+ 'Tools:',
896
+ '- device_detect: find which platforms (ios/android) a directory supports and what is attached. Use it first when a project may be mobile.',
897
+ '- device_run: build, install and launch (action "run"), cancel/stop (action "stop"), or report state (action "status").',
898
+ ' Pass directory when the project root is not the working directory. A run takes minutes; use a generous timeout.',
899
+ '- device_screen: capture the attached Android screen as a PNG plus the UI hierarchy and OCR text, so you can see what',
900
+ ' the app shows and tap by pixel coordinates. Call it after a run to confirm the app rendered, and to drive UI flows.',
901
+ '- device_input: tap/swipe/type/press on the attached Android device at ABSOLUTE pixel coordinates (take the center of a',
902
+ ' device_screen box: x=(x1+x2)/2, y=(y1+y2)/2). The control loop is device_screen → device_input → device_screen.',
903
+ '- device_log: read device logs (logcat main/crash/events, kernel dmesg). Call it when a run fails or an app misbehaves.',
904
+ '- device_status: one normalized snapshot of attached devices, AVDs, running/parked projects, Metro and preview servers.',
905
+ '',
906
+ 'Expo and React Native projects are handled automatically: expo prebuild runs when needed, Metro starts for you,',
907
+ 'and the app is installed and launched on the booted simulator/emulator. Failed builds report the error and a log tail.',
908
+ ].join('\n')
909
+ }
910
+
911
+ // ── mount ─────────────────────────────────────────────────────────────────────
912
+
913
+ export function apply(ctx, config) {
914
+ Setup.ensureHome()
915
+ Setup.writeOcrReadme()
916
+
917
+ const resolve = () => ({
918
+ enabled: config?.enabled ?? true,
919
+ announceToAgent: config?.announceToAgent ?? true,
920
+ })
921
+
922
+ const engine = new DevicePreviewEngine()
923
+ const handle = {
924
+ engine,
925
+ status: () => ({
926
+ directories: [...new Set([...engine.builds.keys()].map((key) => key.split('\0')[0]))],
927
+ servers: [...engine.servers.keys()],
928
+ bundlers: [...engine.bundlers.keys()],
929
+ builds: [...engine.builds.keys()],
930
+ }),
931
+ }
932
+ if (typeof ctx.provide === 'function') ctx.provide('mobilecode', handle)
933
+ else ctx.mobilecode = handle
934
+
935
+ const routes = makeRoutes(engine, config)
936
+ let disposeRoutes
937
+ let disposeTools
938
+ let disposeSection
939
+
940
+ const sync = () => {
941
+ const value = resolve()
942
+ if (disposeSection !== undefined) { disposeSection(); disposeSection = undefined }
943
+ if (disposeRoutes !== undefined) { disposeRoutes(); disposeRoutes = undefined }
944
+ if (disposeTools !== undefined) { disposeTools(); disposeTools = undefined }
945
+ if (!value.enabled) return
946
+ if (value.announceToAgent) {
947
+ disposeSection = ctx.systemPrompt.section({
948
+ name: 'plugin:dsh-mobilecode',
949
+ order: SECTION_ORDER,
950
+ text: guidance,
951
+ })
952
+ }
953
+ disposeRoutes = ctx.effect(() => {
954
+ const disposers = routes.map((route) => ctx.webServer.register(route))
955
+ return () => { for (const dispose of disposers) dispose() }
956
+ }, 'dsh-mobilecode: routes')
957
+ disposeTools = ctx.effect(() => {
958
+ const disposers = [
959
+ deviceRunTool(engine, config),
960
+ deviceDetectTool(engine, config),
961
+ deviceScreenTool(engine),
962
+ deviceLogTool(engine),
963
+ deviceStatusTool(engine),
964
+ deviceInputTool(),
965
+ ].map((tool) => ctx.tools.register(tool))
966
+ return () => { for (const dispose of disposers) dispose() }
967
+ }, 'dsh-mobilecode: tools')
968
+ }
969
+
970
+ ctx.effect(() => () => {
971
+ void engine.dispose()
972
+ }, 'dsh-mobilecode: engine')
973
+
974
+ sync()
975
+ return sync
976
+ }