dsh-mobilecode 0.5.0 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -763,6 +763,63 @@ async function adbConnect(serial) {
763
763
  return /connected|already connected/i.test(`${result.out}\n${result.err}`)
764
764
  }
765
765
 
766
+ /** Attach a Wi-Fi device: `adb connect <ip>:<port>`; true on success. Never throws (returns false with no side effect). */
767
+ export async function adbConnectSerial(serial) {
768
+ return adbConnect(serial)
769
+ }
770
+
771
+ /**
772
+ * Pair a Wi-Fi device (Android 11+ requires pairing before the first connect):
773
+ * `adb pair <ip>:<pair-port> <code>`. Resolves when pairing succeeded.
774
+ */
775
+ export async function adbPair(serial, code) {
776
+ const result = await captureFull(adb(), ["pair", serial, String(code)], { timeoutMs: 20_000 })
777
+ if (/successfully paired/i.test(`${result.out}\n${result.err}`)) return true
778
+ throw new Error(`adb pair ${serial} failed: ${adbFailTail(result) || "no output"}`)
779
+ }
780
+
781
+ /**
782
+ * Parse `adb mdns services` output for the pairing service the device advertises
783
+ * while its "pair using QR code" dialog is open. Returns the `ip:port` the
784
+ * pairing broadcast listens on, or undefined.
785
+ */
786
+ export function parseMdnsPairing(output) {
787
+ const line = String(output).split(/\r?\n/).find((row) => row.includes("_adb-tls-pairing._tcp"))
788
+ if (line === undefined) return undefined
789
+ const address = /(\d{1,3}(?:\.\d{1,3}){3}:\d+)/.exec(line)?.[1] ?? /(\[[0-9a-f:]+\]:\d+)/.exec(line)?.[1]
790
+ return address ?? undefined
791
+ }
792
+
793
+ /** A Wi-Fi QR code the device scanner accepts: `WIFI:T:ADB;S:<name>;P:<password>;;`. */
794
+ export function generateQrAdbWifi(name, password) {
795
+ return `WIFI:T:ADB;S:${name};P:${password};;`
796
+ }
797
+
798
+ /**
799
+ * Poll `adb mdns services` until the phone's pairing dialog advertises
800
+ * `_adb-tls-pairing._tcp` (resolves to its `ip:port`), or undefined on timeout.
801
+ * Requires adb with mdns support (>= 31). Non-serial, read-only.
802
+ */
803
+ export async function waitForMdnsPairing(timeoutMs = 60_000, pollMs = 1_500) {
804
+ const deadline = Date.now() + timeoutMs
805
+ for (;;) {
806
+ const result = await captureFull(adb(), ["mdns", "services"], { timeoutMs: 5_000 }).catch(() => null)
807
+ if (result && result.code === 0) {
808
+ const address = parseMdnsPairing(result.out)
809
+ if (address !== undefined) return address
810
+ }
811
+ if (Date.now() >= deadline) return undefined
812
+ await new Promise((resolve) => setTimeout(resolve, pollMs))
813
+ }
814
+ }
815
+
816
+ const QR_NAME_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
817
+ /** Random ADB QR name (ADB_WIFI_xxxxxxxxxxxxxx-yyyyyy) + 21-char password, matching Android's own structure. */
818
+ export function randomQrCredentials(prefix = "ADB_WIFI") {
819
+ const pick = (n) => Array.from({ length: n }, () => QR_NAME_CHARS[Math.floor(Math.random() * QR_NAME_CHARS.length)]).join("")
820
+ return { name: `${prefix}_${pick(14)}-${pick(6)}`, password: pick(21) }
821
+ }
822
+
766
823
  /**
767
824
  * The single classified boundary for every serial-targeted adb command.
768
825
  * On a transient transport failure with a Wi-Fi serial (ip:port) it attempts
@@ -808,6 +865,118 @@ export function adbIntentArgs({ action, uri, component } = {}) {
808
865
  return parts
809
866
  }
810
867
 
868
+ // ── perf / app-info parsers (pattern credited to newborne/dsh-adb-ultimate) ──
869
+
870
+ /** Parse `cat /proc/meminfo` into {totalKb, availableKb, usedKb, usedPercent}. */
871
+ export function parseMeminfo(output) {
872
+ const grab = (name) => {
873
+ const match = new RegExp(`^${name}:\\s*(\\d+)`, "m").exec(String(output))
874
+ return match ? Number(match[1]) : undefined
875
+ }
876
+ const totalKb = grab("MemTotal")
877
+ const availableKb = grab("MemAvailable")
878
+ if (totalKb === undefined) return undefined
879
+ const usedKb = availableKb === undefined ? undefined : totalKb - availableKb
880
+ return {
881
+ totalKb,
882
+ availableKb,
883
+ usedKb,
884
+ usedPercent: usedKb !== undefined && totalKb > 0 ? Math.round((usedKb / totalKb) * 1000) / 10 : undefined,
885
+ }
886
+ }
887
+
888
+ /** Parse `dumpsys battery` into {level, temperatureC, status, health, powered}. */
889
+ export function parseBattery(output) {
890
+ const grab = (name) => {
891
+ const match = new RegExp(`^\\s*${name}:\\s*(.+)`, "m").exec(String(output))
892
+ return match ? match[1].trim() : undefined
893
+ }
894
+ const levelRaw = grab("level")
895
+ const tempRaw = grab("temperature")
896
+ const level = levelRaw ? Number(levelRaw) : undefined
897
+ const tempRawNum = tempRaw ? Number(tempRaw) : undefined
898
+ return {
899
+ level,
900
+ temperatureC: tempRawNum !== undefined && Number.isFinite(tempRawNum) ? Math.round(tempRawNum) / 10 : undefined,
901
+ status: grab("status"),
902
+ health: grab("health"),
903
+ powered: grab("AC powered") === "true" || grab("USB powered") === "true" || grab("Wireless powered") === "true",
904
+ }
905
+ }
906
+
907
+ /** Parse `cat /proc/cpuinfo` into {cores, hardware}. */
908
+ export function parseCpuinfo(output) {
909
+ const text = String(output)
910
+ const cores = (text.match(/^processor\s*:/gm) ?? []).length
911
+ const hardware = /^Hardware\s*:\s*(.+)$/m.exec(text)?.[1]?.trim()
912
+ return { cores: cores > 0 ? cores : undefined, hardware }
913
+ }
914
+
915
+ /**
916
+ * Deep-clean a parsed value for a DSH tool output: drop `undefined` keys (and
917
+ * undefined array holes) and turn non-finite numbers into `null`, so the value
918
+ * round-trips JSON losslessly (DSH rejects outputs that lose keys/values on
919
+ * serialize). Leaves everything else untouched; primitives pass through.
920
+ */
921
+ export function jsonSafe(value) {
922
+ if (Array.isArray(value)) {
923
+ const out = []
924
+ for (const item of value) {
925
+ if (item !== undefined) out.push(jsonSafe(item))
926
+ }
927
+ return out
928
+ }
929
+ if (value !== null && typeof value === "object") {
930
+ const out = {}
931
+ for (const [key, item] of Object.entries(value)) {
932
+ if (item !== undefined) out[key] = jsonSafe(item)
933
+ }
934
+ return out
935
+ }
936
+ if (typeof value === "number" && !Number.isFinite(value)) return null
937
+ return value
938
+ }
939
+
940
+ /** Parse `dumpsys package <pkg>` into {versionName, versionCode, minSdk, targetSdk, permissions, activities}. */
941
+ export function parseAppInfo(output, packageName) {
942
+ const text = String(output)
943
+ const grab = (name) => {
944
+ const match = new RegExp(`${name}=(\\S+)`, "m").exec(text)
945
+ return match ? match[1] : undefined
946
+ }
947
+ const permissions = []
948
+ const textLines = text.split(/\r?\n/)
949
+ const headerIndex = textLines.findIndex((line) => line.trim() === "requested permissions:")
950
+ if (headerIndex >= 0) {
951
+ for (let i = headerIndex + 1; i < textLines.length; i += 1) {
952
+ const trimmed = textLines[i].trim()
953
+ if (trimmed === "" || !/^(android\.permission\.|com\.[^\s]+\.permission\.)[A-Z0-9_.]+$/.test(trimmed)) break
954
+ permissions.push(trimmed)
955
+ }
956
+ }
957
+ const activities = []
958
+ const resolverIndex = textLines.findIndex((line) => line.trim() === "Activity Resolver Table:")
959
+ if (resolverIndex >= 0) {
960
+ // Real resolver-table rows are `<hex> pkg/.Activity filter <hex>` (or `pkg/.Activity (hex)`):
961
+ // 5f60459 com.android.settings/.Settings$ApnEditorActivity filter 4e9281e
962
+ // MIME-type headers (vnd.android.cursor.item/telephony-carrier:) contain a `-` so the
963
+ // component class [\w.$]+ cannot span it, keeping them out of the match.
964
+ for (let i = resolverIndex + 1; i < textLines.length && activities.length < 20; i += 1) {
965
+ const match = /([A-Za-z_][\w.$]*\/[\w.$]+)\s+(?:filter\s+[0-9a-f]+|\([0-9a-f]+\))/i.exec(textLines[i].trim())
966
+ if (match && !activities.includes(match[1])) activities.push(match[1])
967
+ }
968
+ }
969
+ return {
970
+ package: packageName,
971
+ versionName: grab("versionName"),
972
+ versionCode: grab("versionCode"),
973
+ minSdk: grab("minSdk"),
974
+ targetSdk: grab("targetSdk"),
975
+ permissions: [...new Set(permissions)].slice(0, 60),
976
+ activities: activities.slice(0, 20),
977
+ }
978
+ }
979
+
811
980
  /** SDK emulator binary (emulator.exe on Windows), or undefined. */
812
981
  export function emulatorBinary() {
813
982
  const sdk = androidSdk()
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']
@@ -234,6 +235,60 @@ function makeRoutes(engine, config, stream) {
234
235
  }
235
236
  },
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
+ },
237
292
  // GET /api/dsh-mobilecode/doctor → [{name, ok, detail, fix?}] — plugin health check.
238
293
  {
239
294
  kind: 'exact',
@@ -927,6 +982,392 @@ function deviceWaitForTool() {
927
982
  })
928
983
  }
929
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 DeviceBuild.jsonSafe({
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 DeviceBuild.jsonSafe(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
+
930
1371
  function deviceBootTool() {
931
1372
  return defineTool({
932
1373
  name: 'device_boot',
@@ -1649,6 +2090,16 @@ function guidance() {
1649
2090
  ' expect_text / expect_gone (no separate screenshot needed to know the tap landed).',
1650
2091
  '- device_wait_for: wait for text to appear/disappear (polls the UI tree, falls back to OCR on textless surfaces);',
1651
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.',
1652
2103
  '- device_input: tap/swipe/type/press on the attached Android device at ABSOLUTE pixel coordinates (take the center of a',
1653
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',
1654
2105
  ' device_screen → device_input when a surface exposes no accessibility tree. Typing is ASCII over plain adb; non-ASCII',
@@ -1743,6 +2194,14 @@ export function apply(ctx, config) {
1743
2194
  deviceAppsTool(),
1744
2195
  deviceLaunchAppTool(),
1745
2196
  deviceIntentTool(),
2197
+ deviceScrollToTool(),
2198
+ deviceConnectTool(),
2199
+ devicePairQrTool(),
2200
+ devicePerfTool(),
2201
+ deviceAppInfoTool(),
2202
+ deviceInstallTool(),
2203
+ deviceUninstallTool(),
2204
+ deviceRebootTool(),
1746
2205
  deviceStreamTool(streamHost, streamAccess),
1747
2206
  deviceLogTool(engine),
1748
2207
  deviceStatusTool(engine),