dsh-mobilecode 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js CHANGED
@@ -25,6 +25,7 @@ import * as Vision from './vision.js'
25
25
  import { DevicePreviewEngine } from './device-preview.js'
26
26
  import * as Setup from './setup.js'
27
27
  import { registerMobileSkill } from './skill.js'
28
+ import { existsSync } from 'node:fs'
28
29
 
29
30
  export const name = 'mobilecode'
30
31
  export const inject = ['webServer', 'tools', 'systemPrompt']
@@ -220,6 +221,74 @@ function makeRoutes(engine, config, stream) {
220
221
  writeJson(res, 200, { ok: true })
221
222
  },
222
223
  },
224
+ // GET /api/dsh-mobilecode/connection → {adb, devices:[{serial, state, model?, wifi}]} — connection card data.
225
+ {
226
+ kind: 'exact',
227
+ path: API_BASE + '/connection',
228
+ handler: async (req, res) => {
229
+ if (!guard(req, res)) return
230
+ if ((req.method ?? 'GET') !== 'GET') { writeJson(res, 405, { error: 'method not allowed' }); return }
231
+ try {
232
+ writeJson(res, 200, { adb: adbHostInfo(), devices: await connectionDevices() })
233
+ } catch (error) {
234
+ writeJson(res, 500, { error: error instanceof Error ? error.message : String(error) })
235
+ }
236
+ },
237
+ },
238
+ // POST /api/dsh-mobilecode/connect {host, port?, pairing_code?, pairing_port?} — attach a Wi-Fi device (with optional pairing).
239
+ {
240
+ kind: 'exact',
241
+ path: API_BASE + '/connect',
242
+ handler: async (req, res) => {
243
+ if (!fence(req, res, true) || !isPost(req, res)) return
244
+ const body = await readBody(req, res)
245
+ if (body === undefined) return
246
+ try {
247
+ const host = String(body.host ?? '').trim().replace(/^adb:\/\//, '')
248
+ if (host === '') { writeJson(res, 400, { error: 'host is required' }); return }
249
+ const port = body.port ?? 5555
250
+ const target = `${host}:${port}`
251
+ let paired
252
+ const code = String(body.pairing_code ?? '').trim()
253
+ if (code !== '') {
254
+ const pairPort = body.pairing_port ?? 37000
255
+ await DeviceBuild.adbPair(`${host}:${pairPort}`, code)
256
+ paired = true
257
+ }
258
+ const connected = await DeviceBuild.adbConnectSerial(target)
259
+ if (!connected) { writeJson(res, 409, { error: `adb connect ${target} failed — check the Wi-Fi IP/port and Wireless debugging; pass pairing_code + pairing_port if it needs pairing.` }); return }
260
+ writeJson(res, 200, { ok: true, serial: target, paired: paired === true })
261
+ } catch (error) {
262
+ writeJson(res, 502, { error: error instanceof Error ? error.message : String(error) })
263
+ }
264
+ },
265
+ },
266
+ // POST /api/dsh-mobilecode/pair-qr {timeout_ms?} — WIFI:T:ADB QR + mDNS auto-pair + connect (one blocking call).
267
+ {
268
+ kind: 'exact',
269
+ path: API_BASE + '/pair-qr',
270
+ handler: async (req, res) => {
271
+ if (!fence(req, res, true) || !isPost(req, res)) return
272
+ const body = await readBody(req, res)
273
+ if (body === undefined) return
274
+ try {
275
+ const timeoutMs = Math.min(Math.max(Number(body.timeout_ms) || 60_000, 10_000), 180_000)
276
+ const { name, password } = DeviceBuild.randomQrCredentials()
277
+ const qrText = DeviceBuild.generateQrAdbWifi(name, password)
278
+ const how = 'Render the returned qr_text as a QR code and scan it with the phone (Settings → Connected devices → Pair by QR). Pairing + connect happen automatically once scanned.'
279
+ const pairingSerial = await DeviceBuild.waitForMdnsPairing(timeoutMs)
280
+ if (pairingSerial === undefined) { writeJson(res, 200, { qr_text: qrText, name, password, status: 'timeout', how }); return }
281
+ await DeviceBuild.adbPair(pairingSerial, password)
282
+ const connectHost = pairingSerial.split(':')[0]
283
+ const connected = await DeviceBuild.adbConnectSerial(`${connectHost}:5555`)
284
+ writeJson(res, 200, connected
285
+ ? { qr_text: qrText, name, password, status: 'connected', serial: `${connectHost}:5555`, how }
286
+ : { qr_text: qrText, name, password, status: 'paired-not-connected', serial: pairingSerial, how })
287
+ } catch (error) {
288
+ writeJson(res, 502, { error: error instanceof Error ? error.message : String(error) })
289
+ }
290
+ },
291
+ },
223
292
  // GET /api/dsh-mobilecode/doctor → [{name, ok, detail, fix?}] — plugin health check.
224
293
  {
225
294
  kind: 'exact',
@@ -728,11 +797,10 @@ function deviceInputTool() {
728
797
  async execute(args) {
729
798
  const serial = await requireAndroidDevice(args.serial)
730
799
  const action = args.action ?? 'tap'
731
- const adbArgs = (shell) => ['-s', serial, 'shell', ...shell]
732
800
  switch (action) {
733
801
  case 'tap': {
734
802
  if (typeof args.x !== 'number' || typeof args.y !== 'number') throw new Error('action=tap requires x and y (integer pixels).')
735
- await DeviceBuild.exec(DeviceBuild.adb(), adbArgs(['input', 'tap', String(args.x), String(args.y)])).exit
803
+ await DeviceBuild.adbRun(serial, ['shell', 'input', 'tap', String(args.x), String(args.y)])
736
804
  return { serial, action, sent: `tap ${args.x},${args.y}` }
737
805
  }
738
806
  case 'swipe': {
@@ -740,13 +808,13 @@ function deviceInputTool() {
740
808
  throw new Error('action=swipe requires x, y, x2, y2.')
741
809
  }
742
810
  const duration = args.duration ?? 200
743
- await DeviceBuild.exec(DeviceBuild.adb(), adbArgs(['input', 'swipe', String(args.x), String(args.y), String(args.x2), String(args.y2), String(duration)])).exit
811
+ await DeviceBuild.adbRun(serial, ['shell', 'input', 'swipe', String(args.x), String(args.y), String(args.x2), String(args.y2), String(duration)])
744
812
  return { serial, action, sent: `swipe ${args.x},${args.y}→${args.x2},${args.y2} (${duration}ms)` }
745
813
  }
746
814
  case 'text': {
747
815
  if (typeof args.text !== 'string' || args.text.length === 0) throw new Error('action=text requires a non-empty text string.')
748
816
  if (DeviceBuild.isAsciiInput(args.text)) {
749
- await DeviceBuild.exec(DeviceBuild.adb(), adbArgs(['input', 'text', DeviceBuild.escapeInputText(args.text)])).exit
817
+ await DeviceBuild.adbRun(serial, ['shell', 'input', 'text', DeviceBuild.escapeInputText(args.text)])
750
818
  return { serial, action, sent: `text "${args.text}"` }
751
819
  }
752
820
  // Non-ASCII (CJK, emoji, accented) cannot go through `input text`; the
@@ -765,7 +833,7 @@ function deviceInputTool() {
765
833
  const raw = String(args.key ?? '')
766
834
  const code = /^\d+$/.test(raw) ? Number(raw) : KEYCODES[raw.toLowerCase()]
767
835
  if (!code) throw new Error(`unknown key "${raw}" — use a name from ${Object.keys(KEYCODES).join(', ')} or a raw keycode integer.`)
768
- await DeviceBuild.exec(DeviceBuild.adb(), adbArgs(['input', 'keyevent', String(code)])).exit
836
+ await DeviceBuild.adbRun(serial, ['shell', 'input', 'keyevent', String(code)])
769
837
  return { serial, action, sent: `key ${raw} (${code})` }
770
838
  }
771
839
  default:
@@ -775,7 +843,71 @@ function deviceInputTool() {
775
843
  })
776
844
  }
777
845
 
778
- /** OCR the current screen and report whether `wantedLower` appears; undefined when OCR is unavailable. */
846
+ /** Plain-device adb diagnostics (no serial) for the settings Connection card. */
847
+ export function adbHostInfo() {
848
+ return DeviceBuild.adb()
849
+ }
850
+
851
+ /** Lines of `adb devices -l` after the header rows, parsed for the settings Connection card. */
852
+ export async function connectionDevices() {
853
+ const output = await DeviceBuild.capture(DeviceBuild.adb(), ["devices", "-l"])
854
+ return parseDeviceLongList(output)
855
+ }
856
+
857
+ /** Parse `adb devices -l` into [{serial, state, model, wifi}]. */
858
+ export function parseDeviceLongList(output) {
859
+ return String(output)
860
+ .split(/\r?\n/)
861
+ .map((line) => line.trim())
862
+ .filter((line) => line && !/^List of devices/i.test(line) && !/^\* daemon/i.test(line))
863
+ .map((line) => {
864
+ const [serial, state, ...rest] = line.split(/\s+/)
865
+ const model = /model:(\S+)/.exec(rest.join(" "))?.[1]
866
+ const row = { serial, state: state || "unknown", wifi: DeviceBuild.isWifiSerial(serial) }
867
+ if (model) row.model = model
868
+ return row
869
+ })
870
+ .filter((row) => row.serial)
871
+ }
872
+
873
+ function deviceIntentTool() {
874
+ return defineTool({
875
+ name: 'device_intent',
876
+ description: 'Open anything on the device by Android intent: an action (e.g. android.settings.WIFI_SETTINGS), ' +
877
+ 'a deep-link URI, or an explicit component (package/.Activity). Reaches screens no tap can address — deep ' +
878
+ 'settings pages, app deep links, files. Values are device-shell quoted so metacharacters stay inert.',
879
+ parameters: {
880
+ serial: { type: 'string', description: 'Android device serial. Omit to use the first attached device.' },
881
+ action: { type: 'string', description: 'Intent action, e.g. android.settings.WIFI_SETTINGS or android.intent.action.VIEW.' },
882
+ uri: { type: 'string', description: 'Data URI the intent carries, e.g. geo:0,0?q=Berlin or a https:// deep link.' },
883
+ component: { type: 'string', description: 'Explicit component, e.g. com.android.settings/.Settings.' },
884
+ },
885
+ output: {
886
+ schema: {
887
+ type: 'object',
888
+ additionalProperties: false,
889
+ properties: {
890
+ serial: { type: 'string', required: true },
891
+ intent: { type: 'string', required: true },
892
+ started: { type: 'boolean', required: true },
893
+ },
894
+ },
895
+ render: (_args, value) => [{ type: 'text', text: `Started ${value?.intent} on ${value?.serial}` }],
896
+ },
897
+ async execute(args) {
898
+ const serial = await requireAndroidDevice(args.serial)
899
+ const argv = DeviceBuild.adbIntentArgs({ action: args.action, uri: args.uri, component: args.component })
900
+ if (argv.length === 3) throw new Error('device_intent needs at least one of action, uri or component.')
901
+ const output = await DeviceBuild.adbRun(serial, argv)
902
+ if (/^Error/i.test(output.trim())) throw new Error(`am start refused the intent on ${serial}: ${output.trim().split(/\r?\n/)[0]}`)
903
+ const label = [["-a", args.action], ["-d", args.uri], ["-n", args.component]]
904
+ .filter(([, value]) => value !== undefined && value !== '')
905
+ .map(([flag, value]) => `${flag} ${value}`)
906
+ .join(' ')
907
+ return { serial, intent: label, started: true }
908
+ },
909
+ })
910
+ }
779
911
  async function ocrHasText(serial, wantedLower) {
780
912
  if (!DeviceBuild.ocrPython()) return undefined
781
913
  const png = await DeviceBuild.screenCapture(serial)
@@ -850,6 +982,392 @@ function deviceWaitForTool() {
850
982
  })
851
983
  }
852
984
 
985
+ /** First node matching the tap-element selector (exact, then contains), or undefined. Used by device_scroll_to. */
986
+ function findMatchingNode(roots, selector) {
987
+ const identifier = selector.identifier !== undefined && selector.identifier.trim() !== '' ? selector.identifier.trim() : undefined
988
+ const label = selector.label !== undefined && selector.label.trim() !== '' ? selector.label.trim() : undefined
989
+ if (identifier === undefined && label === undefined) return undefined
990
+ const matchesValue = (actual, wanted, mode) => actual !== undefined && (mode === 'exact' ? actual === wanted : actual.toLowerCase().includes(wanted.toLowerCase()))
991
+ const matchesNode = (node, mode) => {
992
+ if (identifier !== undefined && !matchesValue(node.resourceId, identifier, mode)) return false
993
+ if (label !== undefined && !matchesValue(node.text, label, mode) && !matchesValue(node.contentDesc, label, mode)) return false
994
+ return true
995
+ }
996
+ const flat = UiTree.flattenNodes(roots)
997
+ return flat.find((node) => matchesNode(node, 'exact')) ?? flat.find((node) => matchesNode(node, 'contains'))
998
+ }
999
+
1000
+ function deviceScrollToTool() {
1001
+ return defineTool({
1002
+ name: 'device_scroll_to',
1003
+ description: 'Scroll until an element (by resource_id or text/content-desc, exact then substring) comes into view, then ' +
1004
+ 'tap nothing — just report it. Repeats: read UI tree → check the selector → swipe up (or left) until found or max_swipes ' +
1005
+ 'exhausted. Completes the control loop for long lists: run device_wait_for won\'t scroll, this brings the element to the ' +
1006
+ 'screen so a follow-up device_tap_element can hit it. Returns the found node and how many swipes it took.',
1007
+ parameters: {
1008
+ serial: { type: 'string', description: 'Android device serial. Omit to use the first attached device.' },
1009
+ resource_id: { type: 'string', description: 'The resource-id to find, e.g. com.android.settings:id/some_item.' },
1010
+ text: { type: 'string', description: 'Text or content-desc to find (exact wins; falls back to substring).' },
1011
+ max_swipes: { type: 'integer', description: 'Swipe budget before giving up (default 8).' },
1012
+ direction: { type: 'string', enum: ['up', 'left'], description: 'Which way to swipe (default up).' },
1013
+ },
1014
+ output: {
1015
+ schema: {
1016
+ type: 'object',
1017
+ additionalProperties: false,
1018
+ properties: {
1019
+ serial: { type: 'string', required: true },
1020
+ found: { type: 'boolean', required: true },
1021
+ swipes: { type: 'integer', required: true },
1022
+ node: { type: 'string' },
1023
+ bounds: { type: 'array', items: { type: 'integer' } },
1024
+ x: { type: 'integer' },
1025
+ y: { type: 'integer' },
1026
+ },
1027
+ },
1028
+ render: (_args, value) => {
1029
+ const v = value ?? { serial: '', found: false, swipes: 0 }
1030
+ return [{ type: 'text', text: v.found
1031
+ ? `Found ${v.node} at ${v.bounds?.join(',') ?? ''} (center ${v.x},${v.y}) after ${v.swipes} swipe(s) on ${v.serial} — device_tap_element it (text/resource_id) or tap ${v.x},${v.y} via device_input`
1032
+ : `Not found after ${v.swipes} swipes on ${v.serial} — the element may live behind a deeper navigation, or the selector is wrong (run device_ui_tree / device_screen to check).` }]
1033
+ },
1034
+ },
1035
+ async execute(args) {
1036
+ const serial = await requireAndroidDevice(args.serial)
1037
+ const selector = { identifier: args.resource_id, label: args.text }
1038
+ const maxSwipes = Math.min(Math.max(args.max_swipes ?? 8, 1), 20)
1039
+ const direction = args.direction === 'left' ? 'left' : 'up'
1040
+ // Screen size drives the swipe path so it works on any panel.
1041
+ let swipes = 0
1042
+ for (;;) {
1043
+ const parsed = await UiTree.readUiTree(serial)
1044
+ const found = findMatchingNode(parsed.roots, selector)
1045
+ if (found) {
1046
+ const center = UiTree.boundsCenter(found.bounds)
1047
+ return { serial, found: true, swipes, node: found.text || found.contentDesc || found.resourceId || found.type, bounds: [found.bounds.x, found.bounds.y, found.bounds.w, found.bounds.h], x: center.x, y: center.y }
1048
+ }
1049
+ if (swipes >= maxSwipes) return { serial, found: false, swipes }
1050
+ const sizeStr = await DeviceBuild.adbRun(serial, ['shell', 'wm', 'size']).catch(() => '')
1051
+ const override = /Override size:\s*(\d+)x(\d+)/.exec(sizeStr)
1052
+ const physical = /Physical size:\s*(\d+)x(\d+)/.exec(sizeStr)
1053
+ const match = override ?? physical
1054
+ if (!match) throw new Error(`cannot read the screen size of ${serial} (wm size returned nothing) — cannot swipe`)
1055
+ const w = Number(match[1]); const h = Number(match[2])
1056
+ const fromX = Math.round(w / 2); const fromY = Math.round(h * 0.7); const toY = Math.round(h * 0.3)
1057
+ if (direction === 'left') {
1058
+ await DeviceBuild.adbRun(serial, ['shell', 'input', 'swipe', String(Math.round(w * 0.8)), String(Math.round(h / 2)), String(Math.round(w * 0.2)), String(Math.round(h / 2)), '400'])
1059
+ } else {
1060
+ await DeviceBuild.adbRun(serial, ['shell', 'input', 'swipe', String(fromX), String(fromY), String(fromX), String(toY), '400'])
1061
+ }
1062
+ swipes += 1
1063
+ await new Promise((resolve) => setTimeout(resolve, 500))
1064
+ }
1065
+ },
1066
+ })
1067
+ }
1068
+
1069
+ function deviceConnectTool() {
1070
+ return defineTool({
1071
+ name: 'device_connect',
1072
+ description: 'Attach an Android device over Wi-Fi: `adb connect <host>:<port>` (requires the phone\'s Wireless debugging' +
1073
+ ' "Pair device" flow the first time — pass pairing_code to pair first). Reattaching a known wireless device needs no' +
1074
+ ' code. Returns the connected serial.',
1075
+ parameters: {
1076
+ host: { type: 'string', description: 'IP address (or mDNS name) of the device.' },
1077
+ port: { type: 'integer', description: 'Connect port (default 5555).' },
1078
+ pairing_code: { type: 'string', description: 'Six-digit pairing code shown by "Wireless debugging → Pair device".' },
1079
+ pairing_port: { type: 'integer', description: 'Pairing port shown by the pairing dialog (default 37000).' },
1080
+ },
1081
+ output: {
1082
+ schema: {
1083
+ type: 'object',
1084
+ additionalProperties: false,
1085
+ properties: {
1086
+ serial: { type: 'string', required: true },
1087
+ paired: { type: 'boolean' },
1088
+ connected: { type: 'boolean', required: true },
1089
+ },
1090
+ },
1091
+ render: (_args, value) => {
1092
+ const v = value ?? { serial: '', connected: false }
1093
+ return [{ type: 'text', text: `${v.connected ? 'Connected' : 'Failed to connect'} to ${v.serial}${v.paired ? ' (paired)' : ''}` }]
1094
+ },
1095
+ },
1096
+ async execute(args) {
1097
+ const host = String(args.host ?? '').trim().replace(/^adb:\/\//, '')
1098
+ if (host === '') throw new Error('device_connect requires a host (IP address).')
1099
+ const port = args.port ?? 5555
1100
+ const target = `${host}:${port}`
1101
+ let paired
1102
+ const code = String(args.pairing_code ?? '').trim()
1103
+ if (code !== '') {
1104
+ const pairPort = args.pairing_port ?? 37000
1105
+ await DeviceBuild.adbPair(`${host}:${pairPort}`, code)
1106
+ paired = true
1107
+ }
1108
+ const connected = await DeviceBuild.adbConnectSerial(target)
1109
+ if (!connected) throw new Error(`adb connect ${target} failed — check the Wi-Fi IP/port and that Wireless debugging is on; if it needs pairing, pass pairing_code + pairing_port.`)
1110
+ return { serial: target, paired, connected: true }
1111
+ },
1112
+ })
1113
+ }
1114
+
1115
+ function devicePairQrTool() {
1116
+ return defineTool({
1117
+ name: 'device_pair_qr',
1118
+ description: 'The full Wi-Fi pairing flow: generates a WIFI:T:ADB QR string (render it as a QR code — any QR generator ' +
1119
+ 'works — and scan it with the phone: Settings → Connected devices → Pair by QR), waits for the phone to advertise its ' +
1120
+ 'pairing service over mDNS (`adb mdns services`, needs adb >= 31), then auto-pairs and connects. One call covers ' +
1121
+ 'generate → wait → pair → connect.',
1122
+ parameters: {
1123
+ timeout_ms: { type: 'integer', description: 'How long to wait for the QR scan (default 60000).' },
1124
+ serial: { type: 'string', description: 'Only used to pre-check adb is present; pairing targets whatever the QR scan names.' },
1125
+ },
1126
+ output: {
1127
+ schema: {
1128
+ type: 'object',
1129
+ additionalProperties: false,
1130
+ properties: {
1131
+ qr_text: { type: 'string', required: true },
1132
+ name: { type: 'string', required: true },
1133
+ password: { type: 'string', required: true },
1134
+ status: { type: 'string', required: true },
1135
+ serial: { type: 'string' },
1136
+ how: { type: 'string', required: true },
1137
+ },
1138
+ },
1139
+ render: (_args, value) => {
1140
+ const v = value ?? { qr_text: '', name: '', password: '', status: '' }
1141
+ const lines = [`WIFI:T:ADB QR (status ${v.status})`, ` ${v.qr_text}`, '']
1142
+ if (v.serial) lines.push(`Paired & connected: ${v.serial}`)
1143
+ lines.push('Scan the QR text above with the phone: Settings → Connected devices → Pair by QR. Any QR generator renders it.')
1144
+ return [{ type: 'text', text: lines.join('\n') }]
1145
+ },
1146
+ },
1147
+ async execute(args) {
1148
+ const timeoutMs = Math.min(Math.max(args.timeout_ms ?? 60_000, 10_000), 180_000)
1149
+ const { name, password } = DeviceBuild.randomQrCredentials()
1150
+ const qrText = DeviceBuild.generateQrAdbWifi(name, password)
1151
+ const how = 'Render the returned qr_text as a QR code and scan it with the phone (Settings → Connected devices → Pair by QR). Pairing + connect happen automatically once scanned.'
1152
+ const pairingSerial = await DeviceBuild.waitForMdnsPairing(timeoutMs)
1153
+ if (pairingSerial === undefined) {
1154
+ return { qr_text: qrText, name, password, status: 'timeout', how }
1155
+ }
1156
+ await DeviceBuild.adbPair(pairingSerial, password)
1157
+ // The pairing dialog usually pairs the tls-port; the connect port is 5555 or mdns-advertised — try 5555.
1158
+ const connectHost = pairingSerial.split(':')[0]
1159
+ const connected = await DeviceBuild.adbConnectSerial(`${connectHost}:5555`)
1160
+ if (!connected) {
1161
+ return { qr_text: qrText, name, password, status: 'paired-not-connected', serial: pairingSerial, how }
1162
+ }
1163
+ return { qr_text: qrText, name, password, status: 'connected', serial: `${connectHost}:5555`, how }
1164
+ },
1165
+ })
1166
+ }
1167
+
1168
+ function devicePerfTool() {
1169
+ return defineTool({
1170
+ name: 'device_perf',
1171
+ description: 'One-shot memory / battery / CPU snapshot of an attached Android device (reads /proc/meminfo, dumpsys battery, ' +
1172
+ '/proc/cpuinfo). Use to check the device\'s resource state before or after a run — cheap and deterministic.',
1173
+ parameters: {
1174
+ serial: { type: 'string', description: 'Android device serial. Omit to use the first attached device.' },
1175
+ },
1176
+ output: {
1177
+ schema: {
1178
+ type: 'object',
1179
+ additionalProperties: false,
1180
+ properties: {
1181
+ serial: { type: 'string', required: true },
1182
+ memory: { type: 'object', additionalProperties: true },
1183
+ battery: { type: 'object', additionalProperties: true },
1184
+ cpu: { type: 'object', additionalProperties: true },
1185
+ },
1186
+ },
1187
+ render: (_args, value) => {
1188
+ const v = value ?? { serial: '' }
1189
+ const mem = v.memory ? `RAM ${v.memory.usedPercent}% used (${Math.round(v.memory.usedKb / 1024)}/${Math.round(v.memory.totalKb / 1024)} MB)` : 'RAM n/a'
1190
+ const bat = v.battery && v.battery.level !== undefined ? `Battery ${v.battery.level}% ${v.battery.temperatureC !== undefined ? `(${v.battery.temperatureC}°C) ` : ''}${v.battery.status}` : 'Battery n/a'
1191
+ const cpu = v.cpu && v.cpu.cores ? `CPU ${v.cpu.cores} cores${v.cpu.hardware ? ` (${v.cpu.hardware})` : ''}` : 'CPU n/a'
1192
+ return [{ type: 'text', text: `${v.serial}: ${mem} · ${bat} · ${cpu}` }]
1193
+ },
1194
+ },
1195
+ async execute(args) {
1196
+ const serial = await requireAndroidDevice(args.serial)
1197
+ const [meminfo, battery, cpuinfo] = await Promise.all([
1198
+ DeviceBuild.adbRun(serial, ['shell', 'cat', '/proc/meminfo']).catch(() => ''),
1199
+ DeviceBuild.adbRun(serial, ['shell', 'dumpsys', 'battery']).catch(() => ''),
1200
+ DeviceBuild.adbRun(serial, ['shell', 'cat', '/proc/cpuinfo']).catch(() => ''),
1201
+ ])
1202
+ return {
1203
+ serial,
1204
+ memory: DeviceBuild.parseMeminfo(meminfo),
1205
+ battery: parseBatteryShaped(battery),
1206
+ cpu: DeviceBuild.parseCpuinfo(cpuinfo),
1207
+ }
1208
+ },
1209
+ })
1210
+ }
1211
+
1212
+ /** battery status words: dumpsys battery status 2=charging, 5=full etc. */
1213
+ function parseBatteryShaped(output) {
1214
+ const raw = DeviceBuild.parseBattery(output)
1215
+ if (raw === undefined) return undefined
1216
+ const status = { 1: 'unknown', 2: 'charging', 3: 'discharging', 4: 'not charging', 5: 'full' }[raw.status] ?? raw.status
1217
+ const health = { 1: 'unknown', 2: 'good', 3: 'overheat', 4: 'dead', 5: 'over-voltage', 6: 'unspecified', 7: 'cold' }[raw.health] ?? raw.health
1218
+ return { ...raw, status, health }
1219
+ }
1220
+
1221
+ function deviceAppInfoTool() {
1222
+ return defineTool({
1223
+ name: 'device_app_info',
1224
+ description: 'Detail for one installed app: versionName/versionCode, minSdk/targetSdk, requested permissions, and exported ' +
1225
+ 'activities — straight from `dumpsys package <pkg>`. Use to answer "what does this app do / what permissions / which ' +
1226
+ 'version" without guessing.',
1227
+ parameters: {
1228
+ package: { type: 'string', description: 'Package name, e.g. com.android.chrome.' },
1229
+ serial: { type: 'string', description: 'Android device serial. Omit to use the first attached device.' },
1230
+ },
1231
+ output: {
1232
+ schema: {
1233
+ type: 'object',
1234
+ additionalProperties: false,
1235
+ properties: {
1236
+ package: { type: 'string', required: true },
1237
+ versionName: { type: 'string' },
1238
+ versionCode: { type: 'string' },
1239
+ minSdk: { type: 'string' },
1240
+ targetSdk: { type: 'string' },
1241
+ permissions: { type: 'array', items: { type: 'string' } },
1242
+ activities: { type: 'array', items: { type: 'string' } },
1243
+ },
1244
+ },
1245
+ render: (_args, value) => {
1246
+ const v = value ?? { package: '' }
1247
+ const lines = [`${v.package}`, ` version ${v.versionName ?? '?'} (${v.versionCode ?? '?'}) · minSdk ${v.minSdk ?? '?'} · targetSdk ${v.targetSdk ?? '?'}`]
1248
+ if (v.permissions?.length) lines.push(` permissions (${v.permissions.length}):`, v.permissions.slice(0, 15).map((p) => ` ${p}`).join('\n'))
1249
+ if (v.activities?.length) lines.push(` activities (${v.activities.length}):`, v.activities.slice(0, 8).map((a) => ` ${a}`).join('\n'))
1250
+ return [{ type: 'text', text: lines.join('\n') }]
1251
+ },
1252
+ },
1253
+ async execute(args) {
1254
+ const serial = await requireAndroidDevice(args.serial)
1255
+ const pkg = String(args.package ?? '').trim()
1256
+ if (pkg === '') throw new Error('device_app_info requires a package name.')
1257
+ const output = await DeviceBuild.adbRun(serial, ['shell', 'dumpsys', 'package', pkg])
1258
+ const info = DeviceBuild.parseAppInfo(output, pkg)
1259
+ if (info.versionName === undefined) throw new Error(`package ${pkg} is not installed (dumpsys package returned nothing for it) — run device_apps to list what is.`)
1260
+ return info
1261
+ },
1262
+ })
1263
+ }
1264
+
1265
+ function deviceInstallTool() {
1266
+ return defineTool({
1267
+ name: 'device_install',
1268
+ description: 'Install a local APK onto an attached Android device (`adb install -r -g`: replace + grant runtime permissions). ' +
1269
+ 'The apk_path is a LOCAL path on this host (e.g. a build output). Independent of device_run — use it to sideload a debug ' +
1270
+ 'build onto a device whose app is already set up.',
1271
+ parameters: {
1272
+ apk_path: { type: 'string', description: 'Absolute local path to the APK, e.g. F:/app/build/outputs/apk/debug/app-debug.apk.' },
1273
+ serial: { type: 'string', description: 'Android device serial. Omit to use the first attached device.' },
1274
+ },
1275
+ output: {
1276
+ schema: {
1277
+ type: 'object',
1278
+ additionalProperties: false,
1279
+ properties: {
1280
+ serial: { type: 'string', required: true },
1281
+ apk: { type: 'string', required: true },
1282
+ installed: { type: 'boolean', required: true },
1283
+ },
1284
+ },
1285
+ render: (_args, value) => {
1286
+ const v = value ?? { serial: '', apk: '', installed: false }
1287
+ return [{ type: 'text', text: `Installed ${v.apk} on ${v.serial} (${v.installed ? 'ok' : 'failed'})` }]
1288
+ },
1289
+ },
1290
+ async execute(args) {
1291
+ const serial = await requireAndroidDevice(args.serial)
1292
+ const apk = String(args.apk_path ?? '').trim()
1293
+ if (apk === '') throw new Error('device_install requires apk_path.')
1294
+ if (!existsSync(apk)) throw new Error(`APK not found on this host: ${apk}`)
1295
+ const output = await DeviceBuild.adbRun(serial, ['install', '-r', '-g', apk], { timeoutMs: 180_000 })
1296
+ if (!/success/i.test(output)) throw new Error(`adb install failed on ${serial}: ${output.slice(-300)}`)
1297
+ return { serial, apk, installed: true }
1298
+ },
1299
+ })
1300
+ }
1301
+
1302
+ function deviceUninstallTool() {
1303
+ return defineTool({
1304
+ name: 'device_uninstall',
1305
+ description: 'Uninstall an app from an attached Android device (`adb uninstall <pkg>`, keeps data unless -k is wanted). ' +
1306
+ 'Irreversible — the app\'s data is removed. Confirm the intent before calling.',
1307
+ parameters: {
1308
+ package: { type: 'string', description: 'Package name to remove, e.g. com.example.app.' },
1309
+ keep_data: { type: 'boolean', description: 'Pass -k to keep the app data (default false).' },
1310
+ serial: { type: 'string', description: 'Android device serial. Omit to use the first attached device.' },
1311
+ },
1312
+ output: {
1313
+ schema: {
1314
+ type: 'object',
1315
+ additionalProperties: false,
1316
+ properties: {
1317
+ serial: { type: 'string', required: true },
1318
+ package: { type: 'string', required: true },
1319
+ uninstalled: { type: 'boolean', required: true },
1320
+ },
1321
+ },
1322
+ render: (_args, value) => {
1323
+ const v = value ?? { serial: '', package: '', uninstalled: false }
1324
+ return [{ type: 'text', text: `Uninstalled ${v.package} on ${v.serial} (${v.uninstalled ? 'ok' : 'failed'})` }]
1325
+ },
1326
+ },
1327
+ async execute(args) {
1328
+ const serial = await requireAndroidDevice(args.serial)
1329
+ const pkg = String(args.package ?? '').trim()
1330
+ if (pkg === '') throw new Error('device_uninstall requires a package name.')
1331
+ const argv = args.keep_data === true ? ['uninstall', '-k', pkg] : ['uninstall', pkg]
1332
+ const output = await DeviceBuild.adbRun(serial, argv)
1333
+ if (!/success/i.test(output)) throw new Error(`adb uninstall failed on ${serial}: ${output.slice(-300)}`)
1334
+ return { serial, package: pkg, uninstalled: true }
1335
+ },
1336
+ })
1337
+ }
1338
+
1339
+ function deviceRebootTool() {
1340
+ return defineTool({
1341
+ name: 'device_reboot',
1342
+ description: 'Reboot an attached Android device: `adb reboot` (normal), recovery, or bootloader. The device goes offline and ' +
1343
+ 'comes back in a minute or two — a subsequent call to a device_* tool will auto-wait or fail with a reconnect hint. ' +
1344
+ 'Disruptive: rebooting interrupts anything running on the device.',
1345
+ parameters: {
1346
+ mode: { type: 'string', enum: ['normal', 'recovery', 'bootloader'], description: 'Where to reboot into (default normal).' },
1347
+ serial: { type: 'string', description: 'Android device serial. Omit to use the first attached device.' },
1348
+ },
1349
+ output: {
1350
+ schema: {
1351
+ type: 'object',
1352
+ additionalProperties: false,
1353
+ properties: {
1354
+ serial: { type: 'string', required: true },
1355
+ mode: { type: 'string', required: true },
1356
+ rebooting: { type: 'boolean', required: true },
1357
+ },
1358
+ },
1359
+ render: (_args, value) => [{ type: 'text', text: `Rebooting ${value?.serial} into ${value?.mode}` }],
1360
+ },
1361
+ async execute(args) {
1362
+ const serial = await requireAndroidDevice(args.serial)
1363
+ const mode = args.mode === 'recovery' || args.mode === 'bootloader' ? args.mode : 'normal'
1364
+ const argv = mode === 'normal' ? ['reboot'] : ['reboot', mode]
1365
+ await DeviceBuild.adbRun(serial, argv)
1366
+ return { serial, mode, rebooting: true }
1367
+ },
1368
+ })
1369
+ }
1370
+
853
1371
  function deviceBootTool() {
854
1372
  return defineTool({
855
1373
  name: 'device_boot',
@@ -931,7 +1449,7 @@ function deviceShutdownTool() {
931
1449
  if (!isEmulator) {
932
1450
  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.`)
933
1451
  }
934
- await DeviceBuild.exec(DeviceBuild.adb(), ['-s', serial, 'emu', 'kill']).exit
1452
+ await DeviceBuild.adbRun(serial, ['emu', 'kill'])
935
1453
  return { serial, shutdown: true }
936
1454
  },
937
1455
  })
@@ -971,15 +1489,15 @@ function deviceActionTool() {
971
1489
  const serial = await requireAndroidDevice(args.serial)
972
1490
  const action = String(args.action ?? '')
973
1491
  if (action === 'rotate') {
974
- const current = Number(await DeviceBuild.capture(DeviceBuild.adb(), ['-s', serial, 'shell', 'settings', 'get', 'system', 'user_rotation']))
1492
+ const current = Number(await DeviceBuild.adbRun(serial, ['shell', 'settings', 'get', 'system', 'user_rotation']))
975
1493
  const next = ((Number.isFinite(current) ? current : 0) + 1) % 4
976
- await DeviceBuild.exec(DeviceBuild.adb(), ['-s', serial, 'shell', 'settings', 'put', 'system', 'accelerometer_rotation', '0']).exit
977
- await DeviceBuild.exec(DeviceBuild.adb(), ['-s', serial, 'shell', 'settings', 'put', 'system', 'user_rotation', String(next)]).exit
1494
+ await DeviceBuild.adbRun(serial, ['shell', 'settings', 'put', 'system', 'accelerometer_rotation', '0'])
1495
+ await DeviceBuild.adbRun(serial, ['shell', 'settings', 'put', 'system', 'user_rotation', String(next)])
978
1496
  return { serial, action, rotation: next }
979
1497
  }
980
1498
  const shell = DEVICE_ACTIONS[action]
981
1499
  if (!shell) throw new Error(`unknown action "${action}" — use ${[...Object.keys(DEVICE_ACTIONS), 'rotate'].join(', ')}.`)
982
- await DeviceBuild.exec(DeviceBuild.adb(), ['-s', serial, 'shell', ...shell]).exit
1500
+ await DeviceBuild.adbRun(serial, ['shell', ...shell])
983
1501
  return { serial, action }
984
1502
  },
985
1503
  })
@@ -1012,7 +1530,7 @@ function deviceAppsTool() {
1012
1530
  },
1013
1531
  async execute(args) {
1014
1532
  const serial = await requireAndroidDevice(args.serial)
1015
- const output = await DeviceBuild.capture(DeviceBuild.adb(), ['-s', serial, 'shell', 'pm', 'list', 'packages', ...(args.include_system ? [] : ['-3'])])
1533
+ const output = await DeviceBuild.adbRun(serial, ['shell', 'pm', 'list', 'packages', ...(args.include_system ? [] : ['-3'])])
1016
1534
  const packages = output
1017
1535
  .split(/\r?\n/)
1018
1536
  .map((line) => line.trim())
@@ -1050,7 +1568,7 @@ function deviceLaunchAppTool() {
1050
1568
  const serial = await requireAndroidDevice(args.serial)
1051
1569
  let pkg = String(args.package ?? '').trim()
1052
1570
  if (pkg === '') throw new Error('device_launch_app requires a package name (or a unique substring).')
1053
- const listOut = await DeviceBuild.capture(DeviceBuild.adb(), ['-s', serial, 'shell', 'pm', 'list', 'packages'])
1571
+ const listOut = await DeviceBuild.adbRun(serial, ['shell', 'pm', 'list', 'packages'])
1054
1572
  const all = listOut
1055
1573
  .split(/\r?\n/)
1056
1574
  .map((line) => line.trim())
@@ -1062,9 +1580,9 @@ function deviceLaunchAppTool() {
1062
1580
  if (matches.length > 1) throw new Error(`"${pkg}" matches ${matches.length} packages (${matches.slice(0, 8).join(', ')}${matches.length > 8 ? ', …' : ''}) — be more specific.`)
1063
1581
  pkg = matches[0]
1064
1582
  }
1065
- if (args.relaunch) await DeviceBuild.exec(DeviceBuild.adb(), ['-s', serial, 'shell', 'am', 'force-stop', pkg]).exit
1066
- const code = await DeviceBuild.exec(DeviceBuild.adb(), ['-s', serial, 'shell', 'monkey', '-p', pkg, '-c', 'android.intent.category.LAUNCHER', '1']).exit
1067
- if (code !== 0) throw new Error(`Could not launch ${pkg} (no launcher activity, or monkey failed with exit ${code}).`)
1583
+ if (args.relaunch) await DeviceBuild.adbRun(serial, ['shell', 'am', 'force-stop', pkg])
1584
+ const launched = await DeviceBuild.adbRun(serial, ['shell', 'monkey', '-p', pkg, '-c', 'android.intent.category.LAUNCHER', '1']).catch((error) => { throw new Error(`Could not launch ${pkg}: ${error instanceof Error ? error.message : error}`) })
1585
+ if (/no activities found|no events to send/i.test(launched)) throw new Error(`Could not launch ${pkg} (no launcher activity).`)
1068
1586
  return { serial, package: pkg, launched: true }
1069
1587
  },
1070
1588
  })
@@ -1240,7 +1758,7 @@ function deviceScreenTool(engine, vision) {
1240
1758
  }
1241
1759
 
1242
1760
  async function captureScreenSize(serial) {
1243
- const output = await DeviceBuild.capture(DeviceBuild.adb(), ['-s', serial, 'shell', 'wm', 'size'])
1761
+ const output = await DeviceBuild.adbRun(serial, ['shell', 'wm', 'size'])
1244
1762
  // An `wm size` override wins over the physical panel — the input space is the override.
1245
1763
  const override = /Override size:\s*(\d+)x(\d+)/.exec(output)
1246
1764
  const match = override ?? /Physical size:\s*(\d+)x(\d+)/.exec(output)
@@ -1362,7 +1880,7 @@ function deviceTapElementTool() {
1362
1880
  const selector = { identifier: args.resource_id, label: args.text }
1363
1881
  const { node, matchedBy } = UiTree.resolveTapTarget(parsed.roots, selector, { tool: 'device_tap_element', allowOffscreen: args.allow_offscreen === true })
1364
1882
  const center = UiTree.boundsCenter(node.bounds)
1365
- await DeviceBuild.exec(DeviceBuild.adb(), ['-s', serial, 'shell', 'input', 'tap', String(center.x), String(center.y)]).exit
1883
+ await DeviceBuild.adbRun(serial, ['shell', 'input', 'tap', String(center.x), String(center.y)])
1366
1884
  const describe = () => {
1367
1885
  const parts = []
1368
1886
  if (node.resourceId) parts.push(`resource_id ${node.resourceId}`)
@@ -1572,6 +2090,16 @@ function guidance() {
1572
2090
  ' expect_text / expect_gone (no separate screenshot needed to know the tap landed).',
1573
2091
  '- device_wait_for: wait for text to appear/disappear (polls the UI tree, falls back to OCR on textless surfaces);',
1574
2092
  ' a timeout is a normal matched:false result, never an error. One call replaces an agent-side poll loop.',
2093
+ '- device_scroll_to: scroll until an element (resource_id or text) comes into view, then report it — use before tapping',
2094
+ ' elements that are off-screen in long lists (Settings pages, app lists). Returns the found node + its center tap point.',
2095
+ '- device_connect / device_pair_qr: attach a Wi-Fi device. device_connect does `adb connect host:port` (pass pairing_code +',
2096
+ ' pairing_port for the first-time "Pair device" flow). device_pair_qr does the full journey: generates a WIFI:T:ADB QR',
2097
+ ' string (render it as a QR code and scan it with the phone: Settings → Connected devices → Pair by QR), waits for the',
2098
+ ' pairing service over mDNS, then auto-pairs and connects. Needs adb >= 31 for the QR flow.',
2099
+ '- device_perf: one-shot RAM / battery / CPU snapshot of an attached device (meminfo + dumpsys battery + cpuinfo).',
2100
+ '- device_app_info / device_install / device_uninstall: per-app detail (version, SDK, permissions, activities via dumpsys',
2101
+ ' package) / sideload a local APK onto a running device (`install -r -g`) / remove an app. Uninstall is destructive.',
2102
+ '- device_reboot: reboot the device (normal / recovery / bootloader). The device drops off then comes back; disruptive.',
1575
2103
  '- device_input: tap/swipe/type/press on the attached Android device at ABSOLUTE pixel coordinates (take the center of a',
1576
2104
  ' device_screen box: x=(x1+x2)/2, y=(y1+y2)/2). The control loop is device_ui_tree → device_tap_element, falling back to',
1577
2105
  ' device_screen → device_input when a surface exposes no accessibility tree. Typing is ASCII over plain adb; non-ASCII',
@@ -1579,6 +2107,10 @@ function guidance() {
1579
2107
  '- device_action: notifications / quick_settings / collapse / lock / wake / assistant / rotate.',
1580
2108
  '- device_boot / device_shutdown: boot an AVD by name and wait for boot / shut an emulator down (refuses physical devices).',
1581
2109
  '- device_apps / device_launch_app: list installed packages (never guess a package name) / launch one by package or unique substring.',
2110
+ '- device_intent: open anything by Android intent (action, deep-link URI, or package/.Activity). Reaches screens no tap can address.',
2111
+ ' Every adb command runs through one classified boundary: a dropped Wi-Fi connection (ip:port serial) gets exactly one',
2112
+ ' `adb connect` attempt, read-only commands then replay automatically, and side-effectful ones refuse the replay —',
2113
+ ' a replayed tap could double-tap — and raise "reconnected — call again" instead.',
1582
2114
  '- device_stream: drive the live screen stream the Devices panel shows (status / start an online device / stop). Agents',
1583
2115
  ' that just need to see the screen should prefer device_screen or device_ui_tree.',
1584
2116
  '- device_log: read device logs (logcat main/crash/events, kernel dmesg). Call it when a run fails or an app misbehaves.',
@@ -1661,6 +2193,15 @@ export function apply(ctx, config) {
1661
2193
  deviceActionTool(),
1662
2194
  deviceAppsTool(),
1663
2195
  deviceLaunchAppTool(),
2196
+ deviceIntentTool(),
2197
+ deviceScrollToTool(),
2198
+ deviceConnectTool(),
2199
+ devicePairQrTool(),
2200
+ devicePerfTool(),
2201
+ deviceAppInfoTool(),
2202
+ deviceInstallTool(),
2203
+ deviceUninstallTool(),
2204
+ deviceRebootTool(),
1664
2205
  deviceStreamTool(streamHost, streamAccess),
1665
2206
  deviceLogTool(engine),
1666
2207
  deviceStatusTool(engine),