dsh-mobilecode 0.2.0 → 0.3.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 CHANGED
@@ -18,6 +18,9 @@
18
18
  import { defineTool } from '@deepseek-ai/dsh-tools'
19
19
  import * as DeviceBuild from './device-build.js'
20
20
  import * as UiTree from './uitree.js'
21
+ import * as FrameSource from './frame-source.js'
22
+ import * as StreamAccess from './stream-access.js'
23
+ import { AndroidStreamHost, ROTATION_CYCLE } from './android-stream.js'
21
24
  import { DevicePreviewEngine } from './device-preview.js'
22
25
  import * as Setup from './setup.js'
23
26
  import { registerMobileSkill } from './skill.js'
@@ -70,11 +73,21 @@ function resolveDirectory(body, config) {
70
73
  return process.cwd()
71
74
  }
72
75
 
73
- function makeRoutes(engine, config) {
76
+ function makeRoutes(engine, config, stream) {
74
77
  const guard = (req, res) => {
75
78
  if (!isLoopbackRequest(req)) { writeJson(res, 403, { error: 'forbidden: loopback-only' }); return false }
76
79
  return true
77
80
  }
81
+ // Stronger fence for the stream routes: loopback peer + loopback Host +
82
+ // Sec-Fetch-Site/Origin. POSTs (which mint capabilities) also require Origin.
83
+ const fence = (req, res, requireOrigin) => {
84
+ if (!StreamAccess.isTrustedRequest(req, requireOrigin)) { writeJson(res, 403, { error: 'forbidden: loopback trusted-browser only' }); return false }
85
+ return true
86
+ }
87
+ const isPost = (req, res) => {
88
+ if ((req.method ?? 'GET') !== 'POST') { writeJson(res, 405, { error: 'method not allowed' }); return false }
89
+ return true
90
+ }
78
91
  const platformOf = (value) => (value === 'ios' || value === 'android' ? value : undefined)
79
92
  const routes = [
80
93
  // GET /api/dsh-mobilecode?directory=... → current info (platforms, servers, builds, bundler).
@@ -290,6 +303,154 @@ function makeRoutes(engine, config) {
290
303
  writeJson(res, 405, { error: 'method not allowed' })
291
304
  },
292
305
  },
306
+ // ── live device stream (panel) ──────────────────────────────────────────
307
+ // GET /api/dsh-mobilecode/stream?token=… — live multipart/x-mixed-replace PNG
308
+ // stream from the in-process frame loop. The <img> GET carries no Origin, so
309
+ // the fence here is loopback-only (requireOrigin false).
310
+ {
311
+ kind: 'exact',
312
+ path: API_BASE + '/stream',
313
+ handler: async (req, res) => {
314
+ if (!fence(req, res, false)) return
315
+ if ((req.method ?? 'GET') !== 'GET') { writeJson(res, 405, { error: 'method not allowed' }); return }
316
+ const token = new URL(req.url ?? '/', 'http://localhost').searchParams.get('token') ?? ''
317
+ const payload = await stream.access.verifyStreamToken(token)
318
+ if (payload === undefined) { writeJson(res, 403, { error: 'the stream token is invalid or expired' }); return }
319
+ if (stream.host.streamedSerial !== payload.serial) { writeJson(res, 503, { error: 'the device stream is not running; request a fresh grant' }); return }
320
+ const release = stream.host.acquire()
321
+ try {
322
+ await stream.host.ensureStreaming({ serial: payload.serial })
323
+ } catch (error) {
324
+ release()
325
+ writeJson(res, 502, { error: `the device stream failed to start: ${error instanceof Error ? error.message : String(error)}` })
326
+ return
327
+ }
328
+ const writer = new FrameSource.MultipartFrameWriter(res)
329
+ let finished = false
330
+ const teardown = () => {
331
+ if (finished) return
332
+ finished = true
333
+ unsubscribe()
334
+ writer.close()
335
+ release()
336
+ }
337
+ const unsubscribe = stream.host.subscribeFrames((frame) => {
338
+ // Frames for a different serial (after a device switch) must not leak
339
+ // into a capability minted for the old device.
340
+ if (stream.host.streamedSerial === payload.serial) writer.writeFrame(frame)
341
+ else teardown()
342
+ })
343
+ res.on('error', teardown)
344
+ res.on('close', teardown)
345
+ const latest = stream.host.latestFrame
346
+ if (latest !== undefined) writer.writeFrame(latest)
347
+ },
348
+ },
349
+ // POST /api/dsh-mobilecode/stream/grant {device?} — mint a fresh stream URL.
350
+ // Only starts the loop for an ONLINE device; never boots an emulator, never
351
+ // yanks the stream from a different streaming device.
352
+ {
353
+ kind: 'exact',
354
+ path: API_BASE + '/stream/grant',
355
+ handler: async (req, res) => {
356
+ if (!fence(req, res, true) || !isPost(req, res)) return
357
+ const body = await readBody(req, res)
358
+ if (body === undefined) return
359
+ try {
360
+ const serial = typeof body.device === 'string' && body.device !== '' ? body.device : stream.host.streamedSerial
361
+ if (!serial) { writeJson(res, 409, { error: 'no device is streaming; pass a serial' }); return }
362
+ if (!StreamAccess.SERIAL_PATTERN.test(serial)) { writeJson(res, 400, { error: 'device must be an adb device serial' }); return }
363
+ if (stream.host.streamedSerial !== serial) {
364
+ const online = await stream.host.listDevices()
365
+ if (!online.some((device) => device.serial === serial)) { writeJson(res, 409, { error: `device ${serial} is not online` }); return }
366
+ }
367
+ await stream.host.ensureStreaming({ serial })
368
+ const signed = await stream.access.signStreamToken(serial)
369
+ writeJson(res, 200, { ok: true, streamUrl: `${API_BASE}/stream?token=${encodeURIComponent(signed.token)}`, expiresAt: signed.expiresAt, device: serial })
370
+ } catch (error) {
371
+ writeJson(res, 502, { error: `the device stream failed to start: ${error instanceof Error ? error.message : String(error)}` })
372
+ }
373
+ },
374
+ },
375
+ // POST /api/dsh-mobilecode/stream/status {device?} — read-only snapshot;
376
+ // never starts a stream and never mints tokens.
377
+ {
378
+ kind: 'exact',
379
+ path: API_BASE + '/stream/status',
380
+ handler: async (req, res) => {
381
+ if (!fence(req, res, true) || !isPost(req, res)) return
382
+ const body = await readBody(req, res)
383
+ if (body === undefined) return
384
+ const status = stream.host.status()
385
+ const filter = body.device
386
+ const running = status.running && status.serial !== undefined && (filter === undefined || filter === '' || status.serial === filter)
387
+ if (!running) { writeJson(res, 200, { ok: true, running: false }); return }
388
+ writeJson(res, 200, { ok: true, running: true, serial: status.serial, width: status.width, height: status.height })
389
+ },
390
+ },
391
+ // POST /api/dsh-mobilecode/stream/devices — online device list for the picker.
392
+ {
393
+ kind: 'exact',
394
+ path: API_BASE + '/stream/devices',
395
+ handler: async (req, res) => {
396
+ if (!fence(req, res, true) || !isPost(req, res)) return
397
+ await readBody(req, res)
398
+ try {
399
+ const devices = await stream.host.listDevices()
400
+ const streamed = stream.host.streamedSerial
401
+ writeJson(res, 200, { ok: true, devices: devices.map((device) => ({ ...device, ...(device.serial === streamed ? { streaming: true } : {}) })) })
402
+ } catch (error) {
403
+ writeJson(res, 503, { error: error instanceof Error ? error.message : String(error) })
404
+ }
405
+ },
406
+ },
407
+ // POST /api/dsh-mobilecode/stream/control {device, action} — one control op.
408
+ // tap/drag coordinates are NORMALIZED 0..1 of the streamed frame.
409
+ {
410
+ kind: 'exact',
411
+ path: API_BASE + '/stream/control',
412
+ handler: async (req, res) => {
413
+ if (!fence(req, res, true) || !isPost(req, res)) return
414
+ const body = await readBody(req, res)
415
+ if (body === undefined) return
416
+ const serial = body.device
417
+ if (typeof serial !== 'string' || !StreamAccess.SERIAL_PATTERN.test(serial)) { writeJson(res, 400, { error: 'device must be an adb device serial' }); return }
418
+ const action = body.action
419
+ if (typeof action !== 'object' || action === null || typeof action.kind !== 'string') { writeJson(res, 400, { error: 'action must be an object with a kind' }); return }
420
+ const point = (x, y) => typeof x === 'number' && typeof y === 'number' && x >= 0 && x <= 1 && y >= 0 && y <= 1
421
+ if (action.kind === 'tap' && !point(action.x, action.y)) { writeJson(res, 400, { error: 'tap needs normalized x,y in 0..1' }); return }
422
+ if (action.kind === 'drag' && !(point(action.fromX, action.fromY) && point(action.toX, action.toY))) { writeJson(res, 400, { error: 'drag needs normalized fromX,fromY,toX,toY in 0..1' }); return }
423
+ if (action.kind === 'button' && (typeof action.name !== 'string' || action.name === '')) { writeJson(res, 400, { error: 'button requires a non-empty name' }); return }
424
+ if (action.kind === 'type' && (typeof action.text !== 'string' || action.text === '')) { writeJson(res, 400, { error: 'type requires a non-empty text' }); return }
425
+ if (stream.host.streamedSerial !== serial) {
426
+ const online = await stream.host.listDevices()
427
+ if (!online.some((device) => device.serial === serial)) { writeJson(res, 409, { error: `device ${serial} is not online` }); return }
428
+ }
429
+ const release = stream.host.acquire()
430
+ try {
431
+ let result = { ok: true }
432
+ switch (action.kind) {
433
+ case 'tap': await stream.host.tap(serial, action.x, action.y); break
434
+ case 'drag': await stream.host.drag(serial, { fromX: action.fromX, fromY: action.fromY, toX: action.toX, toY: action.toY, ...(typeof action.durationMs === 'number' ? { duration: Math.min(5, action.durationMs / 1000) } : {}) }); break
435
+ case 'button': await stream.host.button(serial, action.name); break
436
+ case 'type': await stream.host.type(serial, action.text); break
437
+ case 'rotate': {
438
+ const current = await stream.host.getRotation(serial)
439
+ const next = ROTATION_CYCLE[(ROTATION_CYCLE.indexOf(current) + 1) % ROTATION_CYCLE.length]
440
+ await stream.host.rotate(serial, next)
441
+ result = { ok: true, rotation: next }
442
+ break
443
+ }
444
+ default: writeJson(res, 400, { error: `unknown control action ${JSON.stringify(action.kind)}` }); return
445
+ }
446
+ writeJson(res, 200, result)
447
+ } catch (error) {
448
+ writeJson(res, 502, { error: `the device control failed: ${error instanceof Error ? error.message : String(error)}` })
449
+ } finally {
450
+ release()
451
+ }
452
+ },
453
+ },
293
454
  ]
294
455
  return routes
295
456
  }
@@ -583,9 +744,21 @@ function deviceInputTool() {
583
744
  }
584
745
  case 'text': {
585
746
  if (typeof args.text !== 'string' || args.text.length === 0) throw new Error('action=text requires a non-empty text string.')
586
- const escaped = args.text.replace(/\s/g, '%s')
587
- await DeviceBuild.exec(DeviceBuild.adb(), adbArgs(['input', 'text', escaped])).exit
588
- return { serial, action, sent: `text "${args.text}"` }
747
+ if (DeviceBuild.isAsciiInput(args.text)) {
748
+ await DeviceBuild.exec(DeviceBuild.adb(), adbArgs(['input', 'text', DeviceBuild.escapeInputText(args.text)])).exit
749
+ return { serial, action, sent: `text "${args.text}"` }
750
+ }
751
+ // Non-ASCII (CJK, emoji, accented) cannot go through `input text`; the
752
+ // ADBKeyboard IME is the only adb path. Refuse with the fix, never mangle.
753
+ if (!(await DeviceBuild.adbKeyboardReady(serial))) {
754
+ throw new Error(
755
+ `device_input cannot type non-ASCII text ("${args.text}") over plain adb. Install ADBKeyboard `
756
+ + 'on the device (https://github.com/senzhk/ADBKeyBoard), enable it (ime enable/set '
757
+ + 'com.android.adbkeyboard/.AdbIME), then retry — this tool then types via its base64 broadcast.',
758
+ )
759
+ }
760
+ await DeviceBuild.typeViaAdbKeyboard(serial, args.text)
761
+ return { serial, action, sent: `text "${args.text}" (ADBKeyboard)` }
589
762
  }
590
763
  case 'key': {
591
764
  const raw = String(args.key ?? '')
@@ -601,6 +774,352 @@ function deviceInputTool() {
601
774
  })
602
775
  }
603
776
 
777
+ /** OCR the current screen and report whether `wantedLower` appears; undefined when OCR is unavailable. */
778
+ async function ocrHasText(serial, wantedLower) {
779
+ if (!DeviceBuild.ocrPython()) return undefined
780
+ const png = await DeviceBuild.screenCapture(serial)
781
+ if (!png) return undefined
782
+ const ocr = await DeviceBuild.ocrImage(png).catch(() => [])
783
+ return ocr.some((item) => String(item.text).toLowerCase().includes(wantedLower))
784
+ }
785
+
786
+ function deviceWaitForTool() {
787
+ return defineTool({
788
+ name: 'device_wait_for',
789
+ description: 'Wait for on-screen text to appear or disappear. Polls the uiautomator tree every ~600 ms; when the tree ' +
790
+ 'carries no labels (WebView/Compose/canvas) it falls back to local PaddleOCR. A timeout is a normal matched:false ' +
791
+ 'result, never an error — one call replaces an agent-side poll loop.',
792
+ parameters: {
793
+ serial: { type: 'string', description: 'Android device serial. Omit to use the first attached device.' },
794
+ text: { type: 'string', description: 'Text to wait for (case-insensitive substring).' },
795
+ mode: { type: 'string', enum: ['appear', 'disappear'], description: 'appear (default) waits for the text; disappear waits for it to go away.' },
796
+ timeout_ms: { type: 'integer', description: 'Max wait in ms (default 10000, max 60000).' },
797
+ },
798
+ output: {
799
+ schema: {
800
+ type: 'object',
801
+ additionalProperties: false,
802
+ properties: {
803
+ serial: { type: 'string', required: true },
804
+ text: { type: 'string', required: true },
805
+ mode: { type: 'string', required: true },
806
+ matched: { type: 'boolean', required: true },
807
+ waited_ms: { type: 'integer', required: true },
808
+ source: { type: 'string', required: true },
809
+ },
810
+ },
811
+ render: (_args, value) => {
812
+ const v = value ?? { serial: '', text: '', mode: 'appear', matched: false, waited_ms: 0, source: 'ui_tree' }
813
+ return [{ type: 'text', text: `${v.matched ? 'MATCHED' : 'TIMEOUT'}: "${v.text}" ${v.mode} (${v.source}) after ${v.waited_ms}ms on ${v.serial}` }]
814
+ },
815
+ },
816
+ async execute(args) {
817
+ const serial = await requireAndroidDevice(args.serial)
818
+ const text = String(args.text ?? '').trim()
819
+ if (text === '') throw new Error('device_wait_for requires a non-empty text.')
820
+ const mode = args.mode === 'disappear' ? 'disappear' : 'appear'
821
+ const timeout = Math.min(Math.max(args.timeout_ms ?? 10_000, 500), 60_000)
822
+ const wanted = text.toLowerCase()
823
+ const start = Date.now()
824
+ const deadline = start + timeout
825
+ let source = 'ui_tree'
826
+ for (;;) {
827
+ let labels
828
+ try {
829
+ labels = UiTree.collectLabels((await UiTree.readUiTree(serial)).roots)
830
+ } catch {
831
+ labels = undefined
832
+ }
833
+ let present
834
+ if (labels !== undefined && labels.length > 0) {
835
+ source = 'ui_tree'
836
+ present = labels.some((label) => label.toLowerCase().includes(wanted))
837
+ } else {
838
+ const ocr = await ocrHasText(serial, wanted)
839
+ if (ocr === undefined) { source = 'ui_tree'; present = false }
840
+ else { source = 'ocr'; present = ocr }
841
+ }
842
+ const matched = mode === 'appear' ? present : !present
843
+ const waited = Math.min(timeout, Date.now() - start)
844
+ if (matched) return { serial, text, mode, matched: true, waited_ms: waited, source }
845
+ if (Date.now() >= deadline) return { serial, text, mode, matched: false, waited_ms: timeout, source }
846
+ await new Promise((resolve) => setTimeout(resolve, source === 'ocr' ? 2000 : 600))
847
+ }
848
+ },
849
+ })
850
+ }
851
+
852
+ function deviceBootTool() {
853
+ return defineTool({
854
+ name: 'device_boot',
855
+ description: 'Boot an Android emulator AVD by name (from device_status.avds) and wait until it finishes booting. ' +
856
+ 'If an emulator for that AVD is already running it is adopted. This is the device-centric boot — device_run is the ' +
857
+ 'project-centric build+install+launch.',
858
+ parameters: {
859
+ avd: { type: 'string', description: 'AVD name to boot.' },
860
+ timeout_ms: { type: 'integer', description: 'Max wait for boot in ms (default 180000, max 600000).' },
861
+ },
862
+ output: {
863
+ schema: {
864
+ type: 'object',
865
+ additionalProperties: false,
866
+ properties: {
867
+ serial: { type: 'string', required: true },
868
+ avd: { type: 'string', required: true },
869
+ booted: { type: 'boolean', required: true },
870
+ alreadyRunning: { type: 'boolean', required: true },
871
+ },
872
+ },
873
+ render: (_args, value) => {
874
+ const v = value ?? { serial: '', avd: '', booted: false, alreadyRunning: false }
875
+ return [{ type: 'text', text: `${v.alreadyRunning ? 'Adopted running' : 'Booted'} emulator ${v.serial} (AVD ${v.avd}), boot completed: ${v.booted}` }]
876
+ },
877
+ },
878
+ async execute(args) {
879
+ const avd = String(args.avd ?? '').trim()
880
+ if (avd === '') throw new Error('device_boot requires an avd name (see device_status.avds).')
881
+ const timeout = Math.min(Math.max(args.timeout_ms ?? 180_000, 5_000), 600_000)
882
+ const rows = await DeviceBuild.devices()
883
+ for (const row of rows.filter((item) => item.state === 'device' && item.serial.startsWith('emulator-'))) {
884
+ const name = await DeviceBuild.avdName(row.serial).catch(() => undefined)
885
+ if (name === avd) {
886
+ const booted = await DeviceBuild.waitForBoot(row.serial, timeout)
887
+ return { serial: row.serial, avd, booted, alreadyRunning: true }
888
+ }
889
+ }
890
+ if (!DeviceBuild.emulatorBinary()) throw new Error('No SDK emulator binary found; cannot boot an AVD.')
891
+ const before = new Set(rows.map((item) => item.serial))
892
+ if (!DeviceBuild.bootEmulator(avd)) throw new Error(`Could not launch the emulator for AVD "${avd}".`)
893
+ const deadline = Date.now() + timeout
894
+ let serial
895
+ while (Date.now() < deadline) {
896
+ const now = await DeviceBuild.devices()
897
+ const fresh = now.find((item) => item.serial.startsWith('emulator-') && !before.has(item.serial))
898
+ if (fresh) { serial = fresh.serial; break }
899
+ await new Promise((resolve) => setTimeout(resolve, 1000))
900
+ }
901
+ if (!serial) throw new Error(`No emulator serial appeared for "${avd}" within ${timeout} ms.`)
902
+ const booted = await DeviceBuild.waitForBoot(serial, Math.max(1000, deadline - Date.now()))
903
+ if (!booted) throw new Error(`Emulator ${serial} ("${avd}") did not finish booting within ${timeout} ms.`)
904
+ return { serial, avd, booted: true, alreadyRunning: false }
905
+ },
906
+ })
907
+ }
908
+
909
+ function deviceShutdownTool() {
910
+ return defineTool({
911
+ name: 'device_shutdown',
912
+ description: 'Shut down an emulator (`adb emu kill`). Refuses physical devices — adb has no power-off verb for phones.',
913
+ parameters: {
914
+ serial: { type: 'string', description: 'Android device serial. Omit to use the first attached device.' },
915
+ },
916
+ output: {
917
+ schema: {
918
+ type: 'object',
919
+ additionalProperties: false,
920
+ properties: {
921
+ serial: { type: 'string', required: true },
922
+ shutdown: { type: 'boolean', required: true },
923
+ },
924
+ },
925
+ render: (_args, value) => [{ type: 'text', text: `Shut down emulator ${value?.serial}` }],
926
+ },
927
+ async execute(args) {
928
+ const serial = await requireAndroidDevice(args.serial)
929
+ const isEmulator = serial.startsWith('emulator-') || (await DeviceBuild.avdName(serial).catch(() => undefined)) !== undefined
930
+ if (!isEmulator) {
931
+ throw new Error(`device_shutdown refuses ${serial}: it is a physical device and adb has no power-off verb for phones — use its own power button.`)
932
+ }
933
+ await DeviceBuild.exec(DeviceBuild.adb(), ['-s', serial, 'emu', 'kill']).exit
934
+ return { serial, shutdown: true }
935
+ },
936
+ })
937
+ }
938
+
939
+ const DEVICE_ACTIONS = {
940
+ notifications: ['cmd', 'statusbar', 'expand-notifications'],
941
+ quick_settings: ['cmd', 'statusbar', 'expand-settings'],
942
+ collapse: ['cmd', 'statusbar', 'collapse'],
943
+ lock: ['input', 'keyevent', '223'],
944
+ wake: ['input', 'keyevent', '224'],
945
+ assistant: ['am', 'start', '-a', 'android.intent.action.ASSIST'],
946
+ }
947
+
948
+ function deviceActionTool() {
949
+ return defineTool({
950
+ name: 'device_action',
951
+ description: 'Device-level actions beyond touches: open the notification shade or quick settings, collapse the shade, ' +
952
+ 'lock or wake the screen, launch the assistant, or rotate the display (cycles 0→90→180→270 and pins auto-rotate off).',
953
+ parameters: {
954
+ serial: { type: 'string', description: 'Android device serial. Omit to use the first attached device.' },
955
+ action: { type: 'string', enum: [...Object.keys(DEVICE_ACTIONS), 'rotate'], description: 'Which device action to perform.' },
956
+ },
957
+ output: {
958
+ schema: {
959
+ type: 'object',
960
+ additionalProperties: false,
961
+ properties: {
962
+ serial: { type: 'string', required: true },
963
+ action: { type: 'string', required: true },
964
+ rotation: { type: 'integer' },
965
+ },
966
+ },
967
+ render: (_args, value) => [{ type: 'text', text: value?.rotation !== undefined ? `${value.action} on ${value.serial} → rotation ${value.rotation * 90}°` : `${value?.action} on ${value?.serial}` }],
968
+ },
969
+ async execute(args) {
970
+ const serial = await requireAndroidDevice(args.serial)
971
+ const action = String(args.action ?? '')
972
+ if (action === 'rotate') {
973
+ const current = Number(await DeviceBuild.capture(DeviceBuild.adb(), ['-s', serial, 'shell', 'settings', 'get', 'system', 'user_rotation']))
974
+ const next = ((Number.isFinite(current) ? current : 0) + 1) % 4
975
+ await DeviceBuild.exec(DeviceBuild.adb(), ['-s', serial, 'shell', 'settings', 'put', 'system', 'accelerometer_rotation', '0']).exit
976
+ await DeviceBuild.exec(DeviceBuild.adb(), ['-s', serial, 'shell', 'settings', 'put', 'system', 'user_rotation', String(next)]).exit
977
+ return { serial, action, rotation: next }
978
+ }
979
+ const shell = DEVICE_ACTIONS[action]
980
+ if (!shell) throw new Error(`unknown action "${action}" — use ${[...Object.keys(DEVICE_ACTIONS), 'rotate'].join(', ')}.`)
981
+ await DeviceBuild.exec(DeviceBuild.adb(), ['-s', serial, 'shell', ...shell]).exit
982
+ return { serial, action }
983
+ },
984
+ })
985
+ }
986
+
987
+ function deviceAppsTool() {
988
+ return defineTool({
989
+ name: 'device_apps',
990
+ description: 'List installed Android packages (third-party by default; include_system=true adds platform apps). ' +
991
+ 'Use it to find the real package name before device_launch_app — never guess one.',
992
+ parameters: {
993
+ serial: { type: 'string', description: 'Android device serial. Omit to use the first attached device.' },
994
+ include_system: { type: 'boolean', description: 'Include system/platform packages (default false).' },
995
+ },
996
+ output: {
997
+ schema: {
998
+ type: 'object',
999
+ additionalProperties: false,
1000
+ properties: {
1001
+ serial: { type: 'string', required: true },
1002
+ count: { type: 'integer', required: true },
1003
+ packages: { type: 'array', required: true, items: { type: 'string' } },
1004
+ },
1005
+ },
1006
+ render: (_args, value) => {
1007
+ const v = value ?? { serial: '', count: 0, packages: [] }
1008
+ const shown = v.packages.slice(0, 60).join('\n ')
1009
+ return [{ type: 'text', text: `${v.serial}: ${v.count} packages\n ${shown}${v.count > 60 ? `\n … and ${v.count - 60} more` : ''}` }]
1010
+ },
1011
+ },
1012
+ async execute(args) {
1013
+ const serial = await requireAndroidDevice(args.serial)
1014
+ const output = await DeviceBuild.capture(DeviceBuild.adb(), ['-s', serial, 'shell', 'pm', 'list', 'packages', ...(args.include_system ? [] : ['-3'])])
1015
+ const packages = output
1016
+ .split(/\r?\n/)
1017
+ .map((line) => line.trim())
1018
+ .filter((line) => line.startsWith('package:'))
1019
+ .map((line) => line.slice('package:'.length).trim())
1020
+ return { serial, count: packages.length, packages: packages.slice(0, 200) }
1021
+ },
1022
+ })
1023
+ }
1024
+
1025
+ function deviceLaunchAppTool() {
1026
+ return defineTool({
1027
+ name: 'device_launch_app',
1028
+ description: 'Launch an installed app by package name (or a unique substring of it). relaunch=true force-stops it first ' +
1029
+ 'for a cold start. Resolves the exact package via `pm list packages` so a wrong guess fails loudly instead of ' +
1030
+ 'opening the wrong app.',
1031
+ parameters: {
1032
+ serial: { type: 'string', description: 'Android device serial. Omit to use the first attached device.' },
1033
+ package: { type: 'string', description: 'Package name or a unique substring of it.' },
1034
+ relaunch: { type: 'boolean', description: 'Force-stop the app first (cold start).' },
1035
+ },
1036
+ output: {
1037
+ schema: {
1038
+ type: 'object',
1039
+ additionalProperties: false,
1040
+ properties: {
1041
+ serial: { type: 'string', required: true },
1042
+ package: { type: 'string', required: true },
1043
+ launched: { type: 'boolean', required: true },
1044
+ },
1045
+ },
1046
+ render: (_args, value) => [{ type: 'text', text: `Launched ${value?.package} on ${value?.serial}` }],
1047
+ },
1048
+ async execute(args) {
1049
+ const serial = await requireAndroidDevice(args.serial)
1050
+ let pkg = String(args.package ?? '').trim()
1051
+ if (pkg === '') throw new Error('device_launch_app requires a package name (or a unique substring).')
1052
+ const listOut = await DeviceBuild.capture(DeviceBuild.adb(), ['-s', serial, 'shell', 'pm', 'list', 'packages'])
1053
+ const all = listOut
1054
+ .split(/\r?\n/)
1055
+ .map((line) => line.trim())
1056
+ .filter((line) => line.startsWith('package:'))
1057
+ .map((line) => line.slice('package:'.length).trim())
1058
+ if (!all.includes(pkg)) {
1059
+ const matches = all.filter((item) => item.toLowerCase().includes(pkg.toLowerCase()))
1060
+ if (matches.length === 0) throw new Error(`No installed package matches "${pkg}". Run device_apps to list them.`)
1061
+ if (matches.length > 1) throw new Error(`"${pkg}" matches ${matches.length} packages (${matches.slice(0, 8).join(', ')}${matches.length > 8 ? ', …' : ''}) — be more specific.`)
1062
+ pkg = matches[0]
1063
+ }
1064
+ if (args.relaunch) await DeviceBuild.exec(DeviceBuild.adb(), ['-s', serial, 'shell', 'am', 'force-stop', pkg]).exit
1065
+ const code = await DeviceBuild.exec(DeviceBuild.adb(), ['-s', serial, 'shell', 'monkey', '-p', pkg, '-c', 'android.intent.category.LAUNCHER', '1']).exit
1066
+ if (code !== 0) throw new Error(`Could not launch ${pkg} (no launcher activity, or monkey failed with exit ${code}).`)
1067
+ return { serial, package: pkg, launched: true }
1068
+ },
1069
+ })
1070
+ }
1071
+
1072
+ function deviceStreamTool(host, access) {
1073
+ return defineTool({
1074
+ name: 'device_stream',
1075
+ description: 'Drive the live device screen stream the Devices panel shows. action=start begins the frame loop for an ' +
1076
+ 'online device and returns a signed streamUrl; status reports whether it is running; stop tears it down. This is a ' +
1077
+ 'human-panel feature — agents that just need to see the screen should use device_screen or device_ui_tree instead.',
1078
+ parameters: {
1079
+ action: { type: 'string', enum: ['status', 'start', 'stop'], description: 'What to do (default status).' },
1080
+ serial: { type: 'string', description: 'Device serial (start needs an online device; omit to use the first attached one).' },
1081
+ },
1082
+ output: {
1083
+ schema: {
1084
+ type: 'object',
1085
+ additionalProperties: false,
1086
+ properties: {
1087
+ action: { type: 'string', required: true },
1088
+ running: { type: 'boolean', required: true },
1089
+ serial: { type: 'string' },
1090
+ streamUrl: { type: 'string' },
1091
+ width: { type: 'integer' },
1092
+ height: { type: 'integer' },
1093
+ },
1094
+ },
1095
+ render: (_args, value) => {
1096
+ const v = value ?? { action: 'status', running: false }
1097
+ const text = v.action === 'start' && v.streamUrl
1098
+ ? `Streaming ${v.serial} (${v.width}x${v.height}) — ${v.streamUrl}`
1099
+ : `Stream ${v.action}: running=${v.running}${v.serial ? ` (${v.serial})` : ''}`
1100
+ return [{ type: 'text', text }]
1101
+ },
1102
+ },
1103
+ async execute(args) {
1104
+ const action = args.action ?? 'status'
1105
+ if (action === 'stop') {
1106
+ await host.stop()
1107
+ return { action, running: false }
1108
+ }
1109
+ if (action === 'status') {
1110
+ const s = host.status()
1111
+ return { action, running: s.running, ...(s.serial !== undefined ? { serial: s.serial } : {}), ...(s.width !== undefined ? { width: s.width, height: s.height } : {}) }
1112
+ }
1113
+ const serial = await requireAndroidDevice(args.serial)
1114
+ const online = await host.listDevices()
1115
+ if (!online.some((device) => device.serial === serial)) throw new Error(`device ${serial} is not online; cannot stream it`)
1116
+ const info = await host.ensureStreaming({ serial })
1117
+ const signed = await access.signStreamToken(serial)
1118
+ return { action, running: true, serial: info.serial, width: info.width, height: info.height, streamUrl: `${API_BASE}/stream?token=${encodeURIComponent(signed.token)}` }
1119
+ },
1120
+ })
1121
+ }
1122
+
604
1123
  function deviceScreenTool(engine) {
605
1124
  return defineTool({
606
1125
  name: 'device_screen',
@@ -715,7 +1234,9 @@ function deviceScreenTool(engine) {
715
1234
 
716
1235
  async function captureScreenSize(serial) {
717
1236
  const output = await DeviceBuild.capture(DeviceBuild.adb(), ['-s', serial, 'shell', 'wm', 'size'])
718
- const match = /Physical size:\s*(\d+)x(\d+)/.exec(output)
1237
+ // An `wm size` override wins over the physical panel — the input space is the override.
1238
+ const override = /Override size:\s*(\d+)x(\d+)/.exec(output)
1239
+ const match = override ?? /Physical size:\s*(\d+)x(\d+)/.exec(output)
719
1240
  return match ? { width: Number(match[1]), height: Number(match[2]) } : undefined
720
1241
  }
721
1242
 
@@ -1042,8 +1563,17 @@ function guidance() {
1042
1563
  ' text, and pixel bounds. Narrow with filter/max_depth; on a textless surface (WebView, Compose, canvas) use device_screen.',
1043
1564
  '- device_tap_element: tap a control by resource_id or text/content-desc, with one-call verification via',
1044
1565
  ' expect_text / expect_gone (no separate screenshot needed to know the tap landed).',
1566
+ '- device_wait_for: wait for text to appear/disappear (polls the UI tree, falls back to OCR on textless surfaces);',
1567
+ ' a timeout is a normal matched:false result, never an error. One call replaces an agent-side poll loop.',
1045
1568
  '- device_input: tap/swipe/type/press on the attached Android device at ABSOLUTE pixel coordinates (take the center of a',
1046
- ' device_screen box: x=(x1+x2)/2, y=(y1+y2)/2). The control loop is device_screendevice_input device_screen.',
1569
+ ' device_screen box: x=(x1+x2)/2, y=(y1+y2)/2). The control loop is device_ui_treedevice_tap_element, falling back to',
1570
+ ' device_screen → device_input when a surface exposes no accessibility tree. Typing is ASCII over plain adb; non-ASCII',
1571
+ ' (CJK, emoji) goes through the ADBKeyboard IME when installed, and is refused with the install hint otherwise.',
1572
+ '- device_action: notifications / quick_settings / collapse / lock / wake / assistant / rotate.',
1573
+ '- device_boot / device_shutdown: boot an AVD by name and wait for boot / shut an emulator down (refuses physical devices).',
1574
+ '- device_apps / device_launch_app: list installed packages (never guess a package name) / launch one by package or unique substring.',
1575
+ '- device_stream: drive the live screen stream the Devices panel shows (status / start an online device / stop). Agents',
1576
+ ' that just need to see the screen should prefer device_screen or device_ui_tree.',
1047
1577
  '- device_log: read device logs (logcat main/crash/events, kernel dmesg). Call it when a run fails or an app misbehaves.',
1048
1578
  '- device_status: one normalized snapshot of attached devices, AVDs, running/parked projects, Metro and preview servers.',
1049
1579
  '',
@@ -1072,8 +1602,11 @@ export function apply(ctx, config) {
1072
1602
  })
1073
1603
 
1074
1604
  const engine = new DevicePreviewEngine()
1605
+ const streamHost = new AndroidStreamHost()
1606
+ const streamAccess = new StreamAccess.StreamAccessController()
1075
1607
  const handle = {
1076
1608
  engine,
1609
+ stream: streamHost,
1077
1610
  status: () => ({
1078
1611
  directories: [...new Set([...engine.builds.keys()].map((key) => key.split('\0')[0]))],
1079
1612
  servers: [...engine.servers.keys()],
@@ -1084,7 +1617,7 @@ export function apply(ctx, config) {
1084
1617
  if (typeof ctx.provide === 'function') ctx.provide('mobilecode', handle)
1085
1618
  else ctx.mobilecode = handle
1086
1619
 
1087
- const routes = makeRoutes(engine, config)
1620
+ const routes = makeRoutes(engine, config, { host: streamHost, access: streamAccess })
1088
1621
  let disposeRoutes
1089
1622
  let disposeTools
1090
1623
  let disposeSection
@@ -1095,6 +1628,7 @@ export function apply(ctx, config) {
1095
1628
  if (disposeRoutes !== undefined) { disposeRoutes(); disposeRoutes = undefined }
1096
1629
  if (disposeTools !== undefined) { disposeTools(); disposeTools = undefined }
1097
1630
  if (!value.enabled) return
1631
+ streamHost.startKeepAlive()
1098
1632
  if (value.announceToAgent) {
1099
1633
  disposeSection = ctx.systemPrompt.section({
1100
1634
  name: 'plugin:dsh-mobilecode',
@@ -1113,6 +1647,13 @@ export function apply(ctx, config) {
1113
1647
  deviceScreenTool(engine),
1114
1648
  deviceUiTreeTool(),
1115
1649
  deviceTapElementTool(),
1650
+ deviceWaitForTool(),
1651
+ deviceBootTool(),
1652
+ deviceShutdownTool(),
1653
+ deviceActionTool(),
1654
+ deviceAppsTool(),
1655
+ deviceLaunchAppTool(),
1656
+ deviceStreamTool(streamHost, streamAccess),
1116
1657
  deviceLogTool(engine),
1117
1658
  deviceStatusTool(engine),
1118
1659
  deviceInputTool(),
@@ -1123,6 +1664,7 @@ export function apply(ctx, config) {
1123
1664
 
1124
1665
  ctx.effect(() => () => {
1125
1666
  disposeSkill()
1667
+ void streamHost.dispose()
1126
1668
  void engine.dispose()
1127
1669
  }, 'dsh-mobilecode: engine')
1128
1670