dsh-mobilecode 0.6.1 → 0.7.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,7 @@
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 RowList from './list-rows.js'
21
22
  import * as FrameSource from './frame-source.js'
22
23
  import * as StreamAccess from './stream-access.js'
23
24
  import { AndroidStreamHost, ROTATION_CYCLE } from './android-stream.js'
@@ -25,7 +26,8 @@ import * as Vision from './vision.js'
25
26
  import { DevicePreviewEngine } from './device-preview.js'
26
27
  import * as Setup from './setup.js'
27
28
  import { registerMobileSkill } from './skill.js'
28
- import { existsSync } from 'node:fs'
29
+ import { existsSync, readFileSync } from 'node:fs'
30
+ import { tmpdir } from 'node:os'
29
31
 
30
32
  export const name = 'mobilecode'
31
33
  export const inject = ['webServer', 'tools', 'systemPrompt']
@@ -385,14 +387,14 @@ function makeRoutes(engine, config, stream) {
385
387
  if ((req.method ?? 'GET') !== 'GET') { writeJson(res, 405, { error: 'method not allowed' }); return }
386
388
  const token = new URL(req.url ?? '/', 'http://localhost').searchParams.get('token') ?? ''
387
389
  const payload = await stream.access.verifyStreamToken(token)
388
- if (payload === undefined) { writeJson(res, 403, { error: 'the stream token is invalid or expired' }); return }
389
- if (stream.host.streamedSerial !== payload.serial) { writeJson(res, 503, { error: 'the device stream is not running; request a fresh grant' }); return }
390
+ if (payload === undefined) { writeJson(res, 403, { code: 'token_invalid', error: 'the stream token is invalid or expired' }); return }
391
+ if (stream.host.streamedSerial !== payload.serial) { writeJson(res, 503, { code: 'stream_not_running', error: 'the device stream is not running; request a fresh grant' }); return }
390
392
  const release = stream.host.acquire()
391
393
  try {
392
394
  await stream.host.ensureStreaming({ serial: payload.serial })
393
395
  } catch (error) {
394
396
  release()
395
- writeJson(res, 502, { error: `the device stream failed to start: ${error instanceof Error ? error.message : String(error)}` })
397
+ writeJson(res, 502, { code: 'stream_start_failed', error: `the device stream failed to start: ${error instanceof Error ? error.message : String(error)}` })
396
398
  return
397
399
  }
398
400
  const writer = new FrameSource.MultipartFrameWriter(res)
@@ -458,7 +460,8 @@ function makeRoutes(engine, config, stream) {
458
460
  writeJson(res, 200, { ok: true, running: true, serial: status.serial, width: status.width, height: status.height })
459
461
  },
460
462
  },
461
- // POST /api/dsh-mobilecode/stream/devices — online device list for the picker.
463
+ // POST /api/dsh-mobilecode/stream/devices — online device list for the picker
464
+ // + bootable AVDs (for the "start with device_boot" hint rows).
462
465
  {
463
466
  kind: 'exact',
464
467
  path: API_BASE + '/stream/devices',
@@ -466,11 +469,73 @@ function makeRoutes(engine, config, stream) {
466
469
  if (!fence(req, res, true) || !isPost(req, res)) return
467
470
  await readBody(req, res)
468
471
  try {
469
- const devices = await stream.host.listDevices()
472
+ const [devices, avds] = await Promise.all([
473
+ stream.host.listDevices(),
474
+ DeviceBuild.androidAvds().catch(() => []),
475
+ ])
470
476
  const streamed = stream.host.streamedSerial
471
- writeJson(res, 200, { ok: true, devices: devices.map((device) => ({ ...device, ...(device.serial === streamed ? { streaming: true } : {}) })) })
477
+ writeJson(res, 200, {
478
+ ok: true,
479
+ avds,
480
+ devices: devices.map((device) => ({ ...device, ...(device.serial === streamed ? { streaming: true } : {}) })),
481
+ })
482
+ } catch (error) {
483
+ writeJson(res, 503, { code: 'devices_unavailable', error: error instanceof Error ? error.message : String(error) })
484
+ }
485
+ },
486
+ },
487
+ // POST /api/dsh-mobilecode/stream/still {device?} — capture ONE fresh
488
+ // screencap PNG and serve it; returns {path, width, height, dataUrl}. The
489
+ // panel's Screenshot button uses this (a still is the current pixels, unlike
490
+ // the live multipart loop). Falls back to the streamed device when device is
491
+ // omitted. The PNG is written to a temp dir; dataUrl embeds it so the
492
+ // browser can show it immediately (device_screen writes the same files).
493
+ {
494
+ kind: 'exact',
495
+ path: API_BASE + '/stream/still',
496
+ handler: async (req, res) => {
497
+ if (!fence(req, res, true) || !isPost(req, res)) return
498
+ const body = await readBody(req, res)
499
+ if (body === undefined) return
500
+ try {
501
+ const serial = typeof body.device === 'string' && body.device !== '' ? body.device : stream.host.streamedSerial
502
+ if (!serial) { writeJson(res, 409, { code: 'device_not_found', error: 'no device is streaming; pass a serial' }); return }
503
+ if (!StreamAccess.SERIAL_PATTERN.test(serial)) { writeJson(res, 400, { code: 'bad_request', error: 'device must be an adb device serial' }); return }
504
+ const local = await DeviceBuild.screenCapture(serial, tmpdir())
505
+ if (local === undefined) { writeJson(res, 502, { code: 'capture_failed', error: `could not capture a still of ${serial}` }); return }
506
+ const size = pngSize(local)
507
+ const out = { ok: true, path: local, ...(size ? { width: size.width, height: size.height } : {}) }
508
+ const bytes = readFileSync(local)
509
+ if (bytes.length > 0 && bytes.length < 4 * 1024 * 1024) {
510
+ out.dataUrl = 'data:image/png;base64,' + bytes.toString('base64')
511
+ }
512
+ writeJson(res, 200, out)
513
+ } catch (error) {
514
+ writeJson(res, 502, { code: 'capture_failed', error: `the still capture failed: ${error instanceof Error ? error.message : String(error)}` })
515
+ }
516
+ },
517
+ },
518
+ // POST /api/dsh-mobilecode/stream/device-action {device, action} — the
519
+ // panel device menu: notifications / quick_settings / collapse / lock /
520
+ // wake / assistant (the DEVICE_ACTIONS map device_action uses as a tool).
521
+ {
522
+ kind: 'exact',
523
+ path: API_BASE + '/stream/device-action',
524
+ handler: async (req, res) => {
525
+ if (!fence(req, res, true) || !isPost(req, res)) return
526
+ const body = await readBody(req, res)
527
+ if (body === undefined) return
528
+ const serial = typeof body.device === 'string' && body.device !== '' ? body.device : stream.host.streamedSerial
529
+ if (!serial) { writeJson(res, 409, { code: 'device_not_found', error: 'no device is streaming; pass a device' }); return }
530
+ const action = typeof body.action === 'string' ? body.action : ''
531
+ const argv = DEVICE_ACTIONS[action]
532
+ if (argv === undefined) { writeJson(res, 400, { code: 'unknown_action', error: `unknown device action "${action}"` }); return }
533
+ if (!StreamAccess.SERIAL_PATTERN.test(serial)) { writeJson(res, 400, { code: 'bad_request', error: 'device must be an adb device serial' }); return }
534
+ try {
535
+ await DeviceBuild.adbRun(serial, ['shell', ...argv])
536
+ writeJson(res, 200, { ok: true, action, device: serial })
472
537
  } catch (error) {
473
- writeJson(res, 503, { error: error instanceof Error ? error.message : String(error) })
538
+ writeJson(res, 502, { code: 'device_action_failed', error: `device action "${action}" failed: ${error instanceof Error ? error.message : String(error)}` })
474
539
  }
475
540
  },
476
541
  },
@@ -484,17 +549,17 @@ function makeRoutes(engine, config, stream) {
484
549
  const body = await readBody(req, res)
485
550
  if (body === undefined) return
486
551
  const serial = body.device
487
- if (typeof serial !== 'string' || !StreamAccess.SERIAL_PATTERN.test(serial)) { writeJson(res, 400, { error: 'device must be an adb device serial' }); return }
552
+ if (typeof serial !== 'string' || !StreamAccess.SERIAL_PATTERN.test(serial)) { writeJson(res, 400, { code: 'bad_request', error: 'device must be an adb device serial' }); return }
488
553
  const action = body.action
489
- if (typeof action !== 'object' || action === null || typeof action.kind !== 'string') { writeJson(res, 400, { error: 'action must be an object with a kind' }); return }
554
+ if (typeof action !== 'object' || action === null || typeof action.kind !== 'string') { writeJson(res, 400, { code: 'bad_request', error: 'action must be an object with a kind' }); return }
490
555
  const point = (x, y) => typeof x === 'number' && typeof y === 'number' && x >= 0 && x <= 1 && y >= 0 && y <= 1
491
- if (action.kind === 'tap' && !point(action.x, action.y)) { writeJson(res, 400, { error: 'tap needs normalized x,y in 0..1' }); return }
492
- 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 }
493
- if (action.kind === 'button' && (typeof action.name !== 'string' || action.name === '')) { writeJson(res, 400, { error: 'button requires a non-empty name' }); return }
494
- if (action.kind === 'type' && (typeof action.text !== 'string' || action.text === '')) { writeJson(res, 400, { error: 'type requires a non-empty text' }); return }
556
+ if (action.kind === 'tap' && !point(action.x, action.y)) { writeJson(res, 400, { code: 'bad_request', error: 'tap needs normalized x,y in 0..1' }); return }
557
+ if (action.kind === 'drag' && !(point(action.fromX, action.fromY) && point(action.toX, action.toY))) { writeJson(res, 400, { code: 'bad_request', error: 'drag needs normalized fromX,fromY,toX,toY in 0..1' }); return }
558
+ if (action.kind === 'button' && (typeof action.name !== 'string' || action.name === '')) { writeJson(res, 400, { code: 'bad_request', error: 'button requires a non-empty name' }); return }
559
+ if (action.kind === 'type' && (typeof action.text !== 'string' || action.text === '')) { writeJson(res, 400, { code: 'bad_request', error: 'type requires a non-empty text' }); return }
495
560
  if (stream.host.streamedSerial !== serial) {
496
561
  const online = await stream.host.listDevices()
497
- if (!online.some((device) => device.serial === serial)) { writeJson(res, 409, { error: `device ${serial} is not online` }); return }
562
+ if (!online.some((device) => device.serial === serial)) { writeJson(res, 409, { code: 'device_offline', error: `device ${serial} is not online` }); return }
498
563
  }
499
564
  const release = stream.host.acquire()
500
565
  try {
@@ -511,11 +576,11 @@ function makeRoutes(engine, config, stream) {
511
576
  result = { ok: true, rotation: next }
512
577
  break
513
578
  }
514
- default: writeJson(res, 400, { error: `unknown control action ${JSON.stringify(action.kind)}` }); return
579
+ default: writeJson(res, 400, { code: 'unknown_action', error: `unknown control action ${JSON.stringify(action.kind)}` }); return
515
580
  }
516
581
  writeJson(res, 200, result)
517
582
  } catch (error) {
518
- writeJson(res, 502, { error: `the device control failed: ${error instanceof Error ? error.message : String(error)}` })
583
+ writeJson(res, 502, { code: 'control_failed', error: `the device control failed: ${error instanceof Error ? error.message : String(error)}` })
519
584
  } finally {
520
585
  release()
521
586
  }
@@ -747,6 +812,24 @@ async function requireAndroidDevice(serial) {
747
812
  return target
748
813
  }
749
814
 
815
+ /**
816
+ * PNG IHDR hand-parse: read the 8-byte signature + 24-byte IHDR chunk and pull
817
+ * width/height (big-endian at offsets 16/20). Returns undefined for non-PNGs —
818
+ * the PNG the screencap writes is always 8-bit RGBA non-interlaced, so no
819
+ * deeper parsing is needed.
820
+ */
821
+ function pngSize(filePath) {
822
+ try {
823
+ const bytes = readFileSync(filePath)
824
+ if (bytes.length < 24 || bytes[0] !== 0x89 || bytes[1] !== 0x50 || bytes[2] !== 0x4e || bytes[3] !== 0x47) return undefined
825
+ const width = bytes.readUInt32BE(16)
826
+ const height = bytes.readUInt32BE(20)
827
+ return width > 0 && height > 0 ? { width, height } : undefined
828
+ } catch {
829
+ return undefined
830
+ }
831
+ }
832
+
750
833
  /** Common key names → Android keycode. Anything else can be passed as a raw keycode integer. */
751
834
  const KEYCODES = {
752
835
  back: 4, home: 3, menu: 82, recents: 187, app_switch: 187, enter: 66, tab: 61, space: 62,
@@ -1368,6 +1451,365 @@ function deviceRebootTool() {
1368
1451
  })
1369
1452
  }
1370
1453
 
1454
+ function deviceUiRowsTool() {
1455
+ return defineTool({
1456
+ name: 'device_ui_rows',
1457
+ description: 'Detect the list/feed ROWS on the attached Android screen (Settings pages, inboxes, feeds): every row reports ' +
1458
+ 'its index, an isomorphic group id, its pixel frame, the aggregated visible label, and any parsed counters (e.g. "3万 粉丝" ' +
1459
+ '→ {key:"粉丝", value:30000}, "1.2k likes" → {key:"likes", value:1200}). Rows are the unit the user sees — use this instead of ' +
1460
+ 'device_ui_tree when the screen is a list, then tap a row by index with device_tap_row. Row detection is repetition-based: ' +
1461
+ '>=3 sibling subtrees of one parent sharing a class and near-equal height (tolerance max(8px, 15%)).',
1462
+ parameters: {
1463
+ serial: { type: 'string', description: 'Android device serial. Omit to use the first attached device.' },
1464
+ filter: { type: 'string', description: 'Optional case-insensitive substring; keep only rows whose label contains it.' },
1465
+ },
1466
+ output: {
1467
+ schema: {
1468
+ type: 'object',
1469
+ additionalProperties: false,
1470
+ properties: {
1471
+ serial: { type: 'string', required: true },
1472
+ screen: {
1473
+ type: 'object',
1474
+ additionalProperties: false,
1475
+ properties: {
1476
+ width: { type: 'integer', required: true },
1477
+ height: { type: 'integer', required: true },
1478
+ },
1479
+ },
1480
+ omittedOffscreen: { type: 'integer' },
1481
+ rows: {
1482
+ type: 'array',
1483
+ required: true,
1484
+ items: {
1485
+ type: 'object',
1486
+ additionalProperties: false,
1487
+ properties: {
1488
+ index: { type: 'integer', required: true },
1489
+ group: { type: 'integer', required: true },
1490
+ frame: {
1491
+ type: 'object',
1492
+ additionalProperties: false,
1493
+ properties: {
1494
+ x: { type: 'integer', required: true },
1495
+ y: { type: 'integer', required: true },
1496
+ w: { type: 'integer', required: true },
1497
+ h: { type: 'integer', required: true },
1498
+ },
1499
+ },
1500
+ label: { type: 'string', required: true },
1501
+ counters: {
1502
+ type: 'array',
1503
+ required: true,
1504
+ items: {
1505
+ type: 'object',
1506
+ additionalProperties: false,
1507
+ properties: {
1508
+ key: { type: 'string', required: true },
1509
+ value: { type: 'number', required: true },
1510
+ raw: { type: 'string', required: true },
1511
+ },
1512
+ },
1513
+ },
1514
+ },
1515
+ },
1516
+ },
1517
+ },
1518
+ },
1519
+ render: (_args, value) => {
1520
+ const v = value ?? { serial: '', rows: [] }
1521
+ const lines = [`Device: ${v.serial} — ${v.rows.length} row(s)${v.omittedOffscreen ? ` (${v.omittedOffscreen} off-screen omitted)` : ''}`]
1522
+ for (const row of v.rows.slice(0, 20)) {
1523
+ const counterText = row.counters.length > 0 ? ` [${row.counters.map((c) => `${c.key}=${c.value}`).join(', ')}]` : ''
1524
+ lines.push(` #${row.index} g${row.group} @${row.frame.x},${row.frame.y} ${row.frame.w}x${row.frame.h} — ${row.label || '(no text)'}${counterText}`)
1525
+ }
1526
+ if (v.rows.length > 20) lines.push(` ... and ${v.rows.length - 20} more`)
1527
+ if (v.rows.length === 0) lines.push('No repeated rows detected — the screen may not be a list, or it may need a scroll (see device_scroll_to).')
1528
+ return [{ type: 'text', text: lines.join('\n') }]
1529
+ },
1530
+ },
1531
+ async execute(args) {
1532
+ const serial = await requireAndroidDevice(args.serial)
1533
+ const parsed = await UiTree.readUiTree(serial)
1534
+ const screen = UiTree.screenBoundsOf(parsed.roots)
1535
+ const { rows, omittedOffscreen } = RowList.detectRows(parsed.roots, screen)
1536
+ const filter = args.filter !== undefined && String(args.filter).trim() !== '' ? String(args.filter).trim().toLowerCase() : undefined
1537
+ const out = {
1538
+ serial,
1539
+ screen: { width: screen.width, height: screen.height },
1540
+ omittedOffscreen,
1541
+ rows: rows.filter((row) => filter === undefined || row.label.toLowerCase().includes(filter)),
1542
+ }
1543
+ return DeviceBuild.jsonSafe(out)
1544
+ },
1545
+ })
1546
+ }
1547
+
1548
+ function deviceTapRowTool() {
1549
+ return defineTool({
1550
+ name: 'device_tap_row',
1551
+ description: 'Tap inside a list/feed row by index (from device_ui_rows) at a row-relative position: x,y are fractions of the ' +
1552
+ 'row frame (default 0.5,0.5 = the row center). Optional expect_count {key, delta} turns the tap into one verified round trip: ' +
1553
+ 'the counter must ALREADY be visible in the row before the tap (refused otherwise — never probe an unknown control), and after ' +
1554
+ 'an 800ms settle the row at the same index + position must show the count changed by EXACTLY delta (default +1).',
1555
+ parameters: {
1556
+ serial: { type: 'string', description: 'Android device serial. Omit to use the first attached device.' },
1557
+ row: { type: 'integer', description: 'Zero-based row index from device_ui_rows.', required: true },
1558
+ x: { type: 'number', description: 'Row-relative X fraction 0..1 (0 = left edge, 1 = right edge). Default 0.5.' },
1559
+ y: { type: 'number', description: 'Row-relative Y fraction 0..1. Default 0.5.' },
1560
+ expect_count: {
1561
+ type: 'object',
1562
+ additionalProperties: false,
1563
+ description: 'Verify a counter changed by exactly delta after the tap.',
1564
+ properties: {
1565
+ key: { type: 'string', description: 'Counter key as reported by device_ui_rows (e.g. "粉丝", "likes").', required: true },
1566
+ delta: { type: 'integer', description: 'Expected change: 1 (default) or -1.' },
1567
+ },
1568
+ },
1569
+ },
1570
+ output: {
1571
+ schema: {
1572
+ type: 'object',
1573
+ additionalProperties: false,
1574
+ properties: {
1575
+ serial: { type: 'string', required: true },
1576
+ row: { type: 'integer', required: true },
1577
+ x: { type: 'integer', required: true },
1578
+ y: { type: 'integer', required: true },
1579
+ expect: {
1580
+ type: 'object',
1581
+ additionalProperties: false,
1582
+ properties: {
1583
+ verified: { type: 'boolean', required: true },
1584
+ reason: { type: 'string' },
1585
+ before: { type: 'number' },
1586
+ after: { type: 'number' },
1587
+ },
1588
+ },
1589
+ },
1590
+ },
1591
+ render: (_args, value) => {
1592
+ const v = value ?? { serial: '', row: 0, x: 0, y: 0 }
1593
+ const lines = [`Tapped row #${v.row} at ${v.x},${v.y} on ${v.serial}`]
1594
+ if (v.expect) {
1595
+ lines.push(`Expect_count: ${v.expect.verified ? 'VERIFIED' : 'NOT verified'}${v.expect.reason ? ` — ${v.expect.reason}` : ''}${v.expect.before !== undefined ? ` (${v.expect.before} → ${v.expect.after})` : ''}`)
1596
+ }
1597
+ return [{ type: 'text', text: lines.join('\n') }]
1598
+ },
1599
+ },
1600
+ async execute(args) {
1601
+ const serial = await requireAndroidDevice(args.serial)
1602
+ if (!Number.isInteger(args.row) || args.row < 0) throw new Error('device_tap_row requires a non-negative integer row index (see device_ui_rows).')
1603
+ const parsed = await UiTree.readUiTree(serial)
1604
+ const screen = UiTree.screenBoundsOf(parsed.roots)
1605
+ const { rows } = RowList.detectRows(parsed.roots, screen)
1606
+ const row = rows[args.row]
1607
+ if (row === undefined) throw new Error(`row ${args.row} does not exist — device_ui_rows reported ${rows.length} row(s). Re-run it for fresh indices.`)
1608
+ const fractionX = args.x === undefined ? 0.5 : args.x
1609
+ const fractionY = args.y === undefined ? 0.5 : args.y
1610
+ const expect = args.expect_count
1611
+ if (expect) {
1612
+ const key = RowList.normalizeCountKey(String(expect.key))
1613
+ if (!row.counters.some((c) => c.key === key)) {
1614
+ throw new Error(`refusing to tap: counter "${expect.key}" is not visible in row ${args.row} ("${row.label}") — pass a key device_ui_rows reported.`)
1615
+ }
1616
+ }
1617
+ const point = RowList.planRowTap(rows, args.row, fractionX, fractionY)
1618
+ await DeviceBuild.adbRun(serial, ['shell', 'input', 'tap', String(point.x), String(point.y)])
1619
+ const out = { serial, row: args.row, x: point.x, y: point.y }
1620
+ if (expect) {
1621
+ await new Promise((resolve) => setTimeout(resolve, 800))
1622
+ const fresh = await UiTree.readUiTree(serial).catch(() => undefined)
1623
+ if (fresh === undefined) {
1624
+ out.expect = { verified: false, reason: 'could not re-read the screen after the tap' }
1625
+ } else {
1626
+ const freshRows = RowList.detectRows(fresh.roots, UiTree.screenBoundsOf(fresh.roots)).rows
1627
+ const afterRow = freshRows[args.row]
1628
+ const key = RowList.normalizeCountKey(String(expect.key))
1629
+ const delta = expect.delta === undefined ? 1 : Number(expect.delta)
1630
+ const verification = afterRow !== undefined && RowList.rowsStayedPut(row, afterRow)
1631
+ ? RowList.verifyCountChange(row, afterRow, key, delta)
1632
+ : { verified: false, reason: 'the list moved after the tap (row at this index changed position) — the count could not be verified' }
1633
+ out.expect = {
1634
+ verified: verification.verified,
1635
+ ...(verification.reason ? { reason: verification.reason } : {}),
1636
+ ...(verification.before !== undefined ? { before: verification.before, after: verification.after } : {}),
1637
+ }
1638
+ }
1639
+ }
1640
+ return DeviceBuild.jsonSafe(out)
1641
+ },
1642
+ })
1643
+ }
1644
+
1645
+ function deviceBacktraceTool() {
1646
+ return defineTool({
1647
+ name: 'device_backtrace',
1648
+ description: 'Capture a native/ANR thread backtrace or crash log for an app on the device. Sends SIGQUIT (kill -3) to the process, ' +
1649
+ 'waits for the ART runtime to write the thread dump, then reads the newest /data/anr entry. When /data/anr is unrunnable it falls ' +
1650
+ 'back to the logcat crash buffer and says so (engine field: "anr-trace" | "logcat-crash"). Pass package_name or pid.',
1651
+ parameters: {
1652
+ serial: { type: 'string', description: 'Android device serial. Omit to use the first attached device.' },
1653
+ package_name: { type: 'string', description: 'Package of the process to trace (e.g. com.simspoof.app).' },
1654
+ pid: { type: 'integer', description: 'Explicit pid; resolved from package_name when omitted.' },
1655
+ },
1656
+ output: {
1657
+ schema: {
1658
+ type: 'object',
1659
+ additionalProperties: false,
1660
+ properties: {
1661
+ serial: { type: 'string', required: true },
1662
+ engine: { type: 'string', required: true },
1663
+ package_name: { type: 'string' },
1664
+ pid: { type: 'integer', required: true },
1665
+ trace_path: { type: 'string' },
1666
+ note: { type: 'string' },
1667
+ lines: { type: 'array', items: { type: 'string' }, required: true },
1668
+ },
1669
+ },
1670
+ render: (_args, value) => {
1671
+ const v = value ?? { serial: '', engine: '', pid: 0, lines: [] }
1672
+ const lines = [`Backtrace of pid ${v.pid} on ${v.serial} (engine: ${v.engine})${v.trace_path ? ` — ${v.trace_path}` : ''}`]
1673
+ if (v.note) lines.push(v.note)
1674
+ for (const line of v.lines.slice(-40)) lines.push(` ${line}`)
1675
+ if (v.lines.length > 40) lines.push(` ... ${v.lines.length - 40} more lines`)
1676
+ return [{ type: 'text', text: lines.join('\n') }]
1677
+ },
1678
+ },
1679
+ async execute(args) {
1680
+ const serial = await requireAndroidDevice(args.serial)
1681
+ const packageName = args.package_name !== undefined && args.package_name !== '' ? String(args.package_name) : undefined
1682
+ let pid
1683
+ if (args.pid !== undefined && Number.isInteger(args.pid) && args.pid > 0) {
1684
+ pid = args.pid
1685
+ } else if (packageName) {
1686
+ const pidout = await DeviceBuild.adbRun(serial, ['shell', 'pidof', '-s', DeviceBuild.shQuoteDevice(packageName)]).catch(() => undefined)
1687
+ const parsedPid = Number((pidout ?? '').trim())
1688
+ if (Number.isInteger(parsedPid) && parsedPid > 0) pid = parsedPid
1689
+ if (pid === undefined) {
1690
+ const ps = await DeviceBuild.adbRun(serial, ['shell', 'ps', '-A']).catch(() => '')
1691
+ const match = ps.split(/\r?\n/).find((line) => /\s+\d+\s/.test(line) && line.split(/\s+/).pop() === packageName)
1692
+ pid = match ? Number(/\s+(\d+)\s+/.exec(match)?.[1]) : undefined
1693
+ }
1694
+ }
1695
+ if (pid === undefined) {
1696
+ throw new Error(`device_backtrace could not resolve a running process for ${packageName ? `"${packageName}"` : 'the request'} on ${serial} — pass a package_name of a running app, or an explicit pid.`)
1697
+ }
1698
+ // SIGQUIT: the ART runtime writes the thread dump to /data/anr/.
1699
+ await DeviceBuild.adbRun(serial, ['shell', 'kill', '-3', String(pid)])
1700
+ await new Promise((resolve) => setTimeout(resolve, 1200))
1701
+ const listing = await DeviceBuild.adbRun(serial, ['shell', 'ls', '-t', '/data/anr']).catch(() => undefined)
1702
+ const newest = (listing ?? '').split(/\r?\n/).map((line) => line.trim()).filter((line) => line !== '' && !line.includes(' '))[0]
1703
+ if (newest !== undefined) {
1704
+ const content = await DeviceBuild.adbRun(serial, ['shell', 'cat', DeviceBuild.shQuoteDevice(`/data/anr/${newest}`)], { timeoutMs: 30_000 }).catch(() => undefined)
1705
+ if (content !== undefined && content.trim() !== '') {
1706
+ return DeviceBuild.jsonSafe({
1707
+ serial,
1708
+ engine: 'anr-trace',
1709
+ ...(packageName ? { package_name: packageName } : {}),
1710
+ pid,
1711
+ trace_path: `/data/anr/${newest}`,
1712
+ lines: content.split(/\r?\n/).slice(-250),
1713
+ })
1714
+ }
1715
+ }
1716
+ // Fallback: the logcat crash buffer (FATAL/AndroidRuntime lines), filtered by package when known.
1717
+ const log = await DeviceBuild.adbRun(serial, ['logcat', '-b', 'crash', '-d', '-v', 'time']).catch(() => '')
1718
+ const all = log.split(/\r?\n/).filter((line) => /FATAL EXCEPTION|AndroidRuntime|DEBUG|Abort message/.test(line))
1719
+ const lines = (packageName ? all.filter((line) => line.includes(packageName)) : all)
1720
+ if (lines.length === 0) {
1721
+ // Keep the honest fallback note: anr-trace failed; crash buffer silent.
1722
+ return DeviceBuild.jsonSafe({
1723
+ serial,
1724
+ engine: 'logcat-crash',
1725
+ ...(packageName ? { package_name: packageName } : {}),
1726
+ pid,
1727
+ note: `SIGQUIT sent to pid ${pid}; /data/anr was ${newest === undefined ? 'empty/unreadable' : `read but produced no content for ${newest}`}; the logcat crash buffer has no matching FATAL lines now. The app may not have crashed — check device_log for the main buffer.`,
1728
+ lines: [],
1729
+ })
1730
+ }
1731
+ return DeviceBuild.jsonSafe({
1732
+ serial,
1733
+ engine: 'logcat-crash',
1734
+ ...(packageName ? { package_name: packageName } : {}),
1735
+ pid,
1736
+ note: '/data/anr unreadable; fell back to the logcat crash buffer.',
1737
+ lines: lines.slice(-200),
1738
+ })
1739
+ },
1740
+ })
1741
+ }
1742
+
1743
+ function deviceMeminfoTool() {
1744
+ return defineTool({
1745
+ name: 'device_meminfo',
1746
+ description: 'Read a running app\'s memory profile from `dumpsys meminfo <package>`: TOTAL PSS/RSS/Swap-PSS plus the App Summary heap ' +
1747
+ 'breakdown (Java/Native/Code/Stack/Graphics) and the top PSS categories (mmap regions etc.). Throws when the package has no running ' +
1748
+ 'process. Use to check an app\'s footprint after a run, or to compare builds.',
1749
+ parameters: {
1750
+ serial: { type: 'string', description: 'Android device serial. Omit to use the first attached device.' },
1751
+ package_name: { type: 'string', description: 'Package to profile (see device_apps).', required: true },
1752
+ },
1753
+ output: {
1754
+ schema: {
1755
+ type: 'object',
1756
+ additionalProperties: false,
1757
+ properties: {
1758
+ serial: { type: 'string', required: true },
1759
+ package_name: { type: 'string', required: true },
1760
+ totalPssKb: { type: 'number' },
1761
+ totalRssKb: { type: 'number' },
1762
+ totalSwapPssKb: { type: 'number' },
1763
+ appSummary: {
1764
+ type: 'object',
1765
+ additionalProperties: true,
1766
+ description: 'PSS KB per heap bucket: {javaHeapKb, nativeHeapKb, codeKb, stackKb, graphicsKb}.',
1767
+ },
1768
+ topCategories: {
1769
+ type: 'array',
1770
+ items: {
1771
+ type: 'object',
1772
+ additionalProperties: false,
1773
+ properties: {
1774
+ name: { type: 'string', required: true },
1775
+ pssKb: { type: 'number', required: true },
1776
+ },
1777
+ },
1778
+ },
1779
+ },
1780
+ },
1781
+ render: (_args, value) => {
1782
+ const v = value ?? { serial: '', package_name: '' }
1783
+ const mb = (kb) => (kb === undefined ? '?' : `${Math.round(kb / 1024)} MB`)
1784
+ const lines = [`Memory of ${v.package_name} on ${v.serial}`]
1785
+ lines.push(` TOTAL PSS: ${mb(v.totalPssKb)} RSS: ${mb(v.totalRssKb)} SwapPSS: ${mb(v.totalSwapPssKb)}`)
1786
+ if (v.appSummary && Object.keys(v.appSummary).length > 0) {
1787
+ const labels = { javaHeapKb: 'Java heap', nativeHeapKb: 'Native heap', codeKb: 'Code', stackKb: 'Stack', graphicsKb: 'Graphics' }
1788
+ const parts = []
1789
+ for (const [key, label] of Object.entries(labels)) {
1790
+ if (v.appSummary[key] !== undefined) parts.push(`${label}: ${mb(v.appSummary[key])}`)
1791
+ }
1792
+ if (parts.length > 0) lines.push(` App Summary — ${parts.join(', ')}`)
1793
+ }
1794
+ if (v.topCategories && v.topCategories.length > 0) {
1795
+ lines.push(' Top categories:')
1796
+ for (const category of v.topCategories.slice(0, 6)) lines.push(` - ${category.name}: ${mb(category.pssKb)}`)
1797
+ }
1798
+ return [{ type: 'text', text: lines.join('\n') }]
1799
+ },
1800
+ },
1801
+ async execute(args) {
1802
+ const serial = await requireAndroidDevice(args.serial)
1803
+ const packageName = String(args.package_name ?? '').trim()
1804
+ if (packageName === '') throw new Error('device_meminfo requires a package_name (see device_apps).')
1805
+ const output = await DeviceBuild.adbRun(serial, ['shell', 'dumpsys', 'meminfo', packageName], { timeoutMs: 30_000 })
1806
+ const parsed = DeviceBuild.parsePackageMeminfo(output)
1807
+ if (parsed === undefined) throw new Error(`\`dumpsys meminfo ${packageName}\` produced no process block on ${serial} — is ${packageName} running?`)
1808
+ return DeviceBuild.jsonSafe({ serial, package_name: packageName, ...parsed })
1809
+ },
1810
+ })
1811
+ }
1812
+
1371
1813
  function deviceBootTool() {
1372
1814
  return defineTool({
1373
1815
  name: 'device_boot',
@@ -1387,6 +1829,11 @@ function deviceBootTool() {
1387
1829
  avd: { type: 'string', required: true },
1388
1830
  booted: { type: 'boolean', required: true },
1389
1831
  alreadyRunning: { type: 'boolean', required: true },
1832
+ presentationMeta: {
1833
+ type: 'object',
1834
+ additionalProperties: true,
1835
+ description: 'Projected into ToolResultNode.meta for the conversation stream card.',
1836
+ },
1390
1837
  },
1391
1838
  },
1392
1839
  render: (_args, value) => {
@@ -1403,7 +1850,10 @@ function deviceBootTool() {
1403
1850
  const name = await DeviceBuild.avdName(row.serial).catch(() => undefined)
1404
1851
  if (name === avd) {
1405
1852
  const booted = await DeviceBuild.waitForBoot(row.serial, timeout)
1406
- return { serial: row.serial, avd, booted, alreadyRunning: true }
1853
+ return {
1854
+ serial: row.serial, avd, booted, alreadyRunning: true,
1855
+ presentationMeta: { kind: 'android-stream', device: { serial: row.serial, name: avd } },
1856
+ }
1407
1857
  }
1408
1858
  }
1409
1859
  if (!DeviceBuild.emulatorBinary()) throw new Error('No SDK emulator binary found; cannot boot an AVD.')
@@ -1420,7 +1870,10 @@ function deviceBootTool() {
1420
1870
  if (!serial) throw new Error(`No emulator serial appeared for "${avd}" within ${timeout} ms.`)
1421
1871
  const booted = await DeviceBuild.waitForBoot(serial, Math.max(1000, deadline - Date.now()))
1422
1872
  if (!booted) throw new Error(`Emulator ${serial} ("${avd}") did not finish booting within ${timeout} ms.`)
1423
- return { serial, avd, booted: true, alreadyRunning: false }
1873
+ return {
1874
+ serial, avd, booted: true, alreadyRunning: false,
1875
+ presentationMeta: { kind: 'android-stream', device: { serial, name: avd } },
1876
+ }
1424
1877
  },
1425
1878
  })
1426
1879
  }
@@ -1609,6 +2062,11 @@ function deviceStreamTool(host, access) {
1609
2062
  streamUrl: { type: 'string' },
1610
2063
  width: { type: 'integer' },
1611
2064
  height: { type: 'integer' },
2065
+ presentationMeta: {
2066
+ type: 'object',
2067
+ additionalProperties: true,
2068
+ description: 'Projected into ToolResultNode.meta for the conversation stream card.',
2069
+ },
1612
2070
  },
1613
2071
  },
1614
2072
  render: (_args, value) => {
@@ -1634,7 +2092,14 @@ function deviceStreamTool(host, access) {
1634
2092
  if (!online.some((device) => device.serial === serial)) throw new Error(`device ${serial} is not online; cannot stream it`)
1635
2093
  const info = await host.ensureStreaming({ serial })
1636
2094
  const signed = await access.signStreamToken(serial)
1637
- return { action, running: true, serial: info.serial, width: info.width, height: info.height, streamUrl: `${API_BASE}/stream?token=${encodeURIComponent(signed.token)}` }
2095
+ return {
2096
+ action, running: true, serial: info.serial, width: info.width, height: info.height, streamUrl: `${API_BASE}/stream?token=${encodeURIComponent(signed.token)}`,
2097
+ presentationMeta: {
2098
+ kind: 'android-stream',
2099
+ device: { serial: info.serial },
2100
+ streamRouteId: `dsh-mobilecode/stream/${info.serial}`,
2101
+ },
2102
+ }
1638
2103
  },
1639
2104
  })
1640
2105
  }
@@ -1697,6 +2162,11 @@ function deviceScreenTool(engine, vision) {
1697
2162
  },
1698
2163
  ocrError: { type: 'string' },
1699
2164
  image: Vision.IMAGE_REF_SCHEMA,
2165
+ presentationMeta: {
2166
+ type: 'object',
2167
+ additionalProperties: true,
2168
+ description: 'Projected into ToolResultNode.meta for the conversation screenshot card.',
2169
+ },
1700
2170
  },
1701
2171
  },
1702
2172
  render: (_args, value) => {
@@ -1752,6 +2222,15 @@ function deviceScreenTool(engine, vision) {
1752
2222
  }
1753
2223
  const image = await Vision.maybeAttachScreenshot(vision, png, exec)
1754
2224
  if (image !== undefined) out.image = image
2225
+ if (png) {
2226
+ out.presentationMeta = {
2227
+ kind: 'android-screenshot',
2228
+ device: { serial },
2229
+ path: png,
2230
+ }
2231
+ } else {
2232
+ out.presentationMeta = { kind: 'android-screenshot', device: { serial } }
2233
+ }
1755
2234
  return out
1756
2235
  },
1757
2236
  })
@@ -2115,6 +2594,14 @@ function guidance() {
2115
2594
  ' that just need to see the screen should prefer device_screen or device_ui_tree.',
2116
2595
  '- device_log: read device logs (logcat main/crash/events, kernel dmesg). Call it when a run fails or an app misbehaves.',
2117
2596
  '- device_status: one normalized snapshot of attached devices, AVDs, running/parked projects, Metro and preview servers.',
2597
+ '- device_ui_rows: detect list/feed ROWS on the attached screen (Settings pages, feeds, inboxes) with per-row index, group,',
2598
+ ' frame, aggregated label and parsed counters (e.g. "3万 粉丝" → 粉丝=30000). Use it before tapping a row.',
2599
+ '- device_tap_row: tap inside a row by index at a row-relative position; optional expect_count {key, delta} verifies the',
2600
+ ' counter changed by exactly delta after the tap (refused when the key is not visible first).',
2601
+ '- device_meminfo: `dumpsys meminfo <pkg>` → TOTAL PSS/RSS/Swap-PSS + App Summary heap buckets + top categories. Use it to',
2602
+ ' check a running app\'s footprint.',
2603
+ '- device_backtrace: SIGQUIT an app process and read its newest /data/anr thread dump; falls back to the logcat crash buffer',
2604
+ ' when /data/anr is unreadable (engine field says which). Deterministic crash/ANR capture for debugging.',
2118
2605
  '',
2119
2606
  'Expo and React Native projects are handled automatically: expo prebuild runs when needed, Metro starts for you,',
2120
2607
  'and the app is installed and launched on the booted simulator/emulator. Failed builds report the error and a log tail.',
@@ -2206,6 +2693,10 @@ export function apply(ctx, config) {
2206
2693
  deviceLogTool(engine),
2207
2694
  deviceStatusTool(engine),
2208
2695
  deviceInputTool(),
2696
+ deviceUiRowsTool(),
2697
+ deviceTapRowTool(),
2698
+ deviceBacktraceTool(),
2699
+ deviceMeminfoTool(),
2209
2700
  ].map((tool) => ctx.tools.register(tool))
2210
2701
  return () => { for (const dispose of disposers) dispose() }
2211
2702
  }, 'dsh-mobilecode: tools')