dsh-mobilecode 0.1.4 → 0.2.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.
package/lib/index.js CHANGED
@@ -17,8 +17,10 @@
17
17
 
18
18
  import { defineTool } from '@deepseek-ai/dsh-tools'
19
19
  import * as DeviceBuild from './device-build.js'
20
+ import * as UiTree from './uitree.js'
20
21
  import { DevicePreviewEngine } from './device-preview.js'
21
22
  import * as Setup from './setup.js'
23
+ import { registerMobileSkill } from './skill.js'
22
24
 
23
25
  export const name = 'mobilecode'
24
26
  export const inject = ['webServer', 'tools', 'systemPrompt']
@@ -581,9 +583,21 @@ function deviceInputTool() {
581
583
  }
582
584
  case 'text': {
583
585
  if (typeof args.text !== 'string' || args.text.length === 0) throw new Error('action=text requires a non-empty text string.')
584
- const escaped = args.text.replace(/\s/g, '%s')
585
- await DeviceBuild.exec(DeviceBuild.adb(), adbArgs(['input', 'text', escaped])).exit
586
- return { serial, action, sent: `text "${args.text}"` }
586
+ if (DeviceBuild.isAsciiInput(args.text)) {
587
+ await DeviceBuild.exec(DeviceBuild.adb(), adbArgs(['input', 'text', DeviceBuild.escapeInputText(args.text)])).exit
588
+ return { serial, action, sent: `text "${args.text}"` }
589
+ }
590
+ // Non-ASCII (CJK, emoji, accented) cannot go through `input text`; the
591
+ // ADBKeyboard IME is the only adb path. Refuse with the fix, never mangle.
592
+ if (!(await DeviceBuild.adbKeyboardReady(serial))) {
593
+ throw new Error(
594
+ `device_input cannot type non-ASCII text ("${args.text}") over plain adb. Install ADBKeyboard `
595
+ + 'on the device (https://github.com/senzhk/ADBKeyBoard), enable it (ime enable/set '
596
+ + 'com.android.adbkeyboard/.AdbIME), then retry — this tool then types via its base64 broadcast.',
597
+ )
598
+ }
599
+ await DeviceBuild.typeViaAdbKeyboard(serial, args.text)
600
+ return { serial, action, sent: `text "${args.text}" (ADBKeyboard)` }
587
601
  }
588
602
  case 'key': {
589
603
  const raw = String(args.key ?? '')
@@ -599,6 +613,301 @@ function deviceInputTool() {
599
613
  })
600
614
  }
601
615
 
616
+ /** OCR the current screen and report whether `wantedLower` appears; undefined when OCR is unavailable. */
617
+ async function ocrHasText(serial, wantedLower) {
618
+ if (!DeviceBuild.ocrPython()) return undefined
619
+ const png = await DeviceBuild.screenCapture(serial)
620
+ if (!png) return undefined
621
+ const ocr = await DeviceBuild.ocrImage(png).catch(() => [])
622
+ return ocr.some((item) => String(item.text).toLowerCase().includes(wantedLower))
623
+ }
624
+
625
+ function deviceWaitForTool() {
626
+ return defineTool({
627
+ name: 'device_wait_for',
628
+ description: 'Wait for on-screen text to appear or disappear. Polls the uiautomator tree every ~600 ms; when the tree ' +
629
+ 'carries no labels (WebView/Compose/canvas) it falls back to local PaddleOCR. A timeout is a normal matched:false ' +
630
+ 'result, never an error — one call replaces an agent-side poll loop.',
631
+ parameters: {
632
+ serial: { type: 'string', description: 'Android device serial. Omit to use the first attached device.' },
633
+ text: { type: 'string', description: 'Text to wait for (case-insensitive substring).' },
634
+ mode: { type: 'string', enum: ['appear', 'disappear'], description: 'appear (default) waits for the text; disappear waits for it to go away.' },
635
+ timeout_ms: { type: 'integer', description: 'Max wait in ms (default 10000, max 60000).' },
636
+ },
637
+ output: {
638
+ schema: {
639
+ type: 'object',
640
+ additionalProperties: false,
641
+ properties: {
642
+ serial: { type: 'string', required: true },
643
+ text: { type: 'string', required: true },
644
+ mode: { type: 'string', required: true },
645
+ matched: { type: 'boolean', required: true },
646
+ waited_ms: { type: 'integer', required: true },
647
+ source: { type: 'string', required: true },
648
+ },
649
+ },
650
+ render: (_args, value) => {
651
+ const v = value ?? { serial: '', text: '', mode: 'appear', matched: false, waited_ms: 0, source: 'ui_tree' }
652
+ return [{ type: 'text', text: `${v.matched ? 'MATCHED' : 'TIMEOUT'}: "${v.text}" ${v.mode} (${v.source}) after ${v.waited_ms}ms on ${v.serial}` }]
653
+ },
654
+ },
655
+ async execute(args) {
656
+ const serial = await requireAndroidDevice(args.serial)
657
+ const text = String(args.text ?? '').trim()
658
+ if (text === '') throw new Error('device_wait_for requires a non-empty text.')
659
+ const mode = args.mode === 'disappear' ? 'disappear' : 'appear'
660
+ const timeout = Math.min(Math.max(args.timeout_ms ?? 10_000, 500), 60_000)
661
+ const wanted = text.toLowerCase()
662
+ const start = Date.now()
663
+ const deadline = start + timeout
664
+ let source = 'ui_tree'
665
+ for (;;) {
666
+ let labels
667
+ try {
668
+ labels = UiTree.collectLabels((await UiTree.readUiTree(serial)).roots)
669
+ } catch {
670
+ labels = undefined
671
+ }
672
+ let present
673
+ if (labels !== undefined && labels.length > 0) {
674
+ source = 'ui_tree'
675
+ present = labels.some((label) => label.toLowerCase().includes(wanted))
676
+ } else {
677
+ const ocr = await ocrHasText(serial, wanted)
678
+ if (ocr === undefined) { source = 'ui_tree'; present = false }
679
+ else { source = 'ocr'; present = ocr }
680
+ }
681
+ const matched = mode === 'appear' ? present : !present
682
+ const waited = Math.min(timeout, Date.now() - start)
683
+ if (matched) return { serial, text, mode, matched: true, waited_ms: waited, source }
684
+ if (Date.now() >= deadline) return { serial, text, mode, matched: false, waited_ms: timeout, source }
685
+ await new Promise((resolve) => setTimeout(resolve, source === 'ocr' ? 2000 : 600))
686
+ }
687
+ },
688
+ })
689
+ }
690
+
691
+ function deviceBootTool() {
692
+ return defineTool({
693
+ name: 'device_boot',
694
+ description: 'Boot an Android emulator AVD by name (from device_status.avds) and wait until it finishes booting. ' +
695
+ 'If an emulator for that AVD is already running it is adopted. This is the device-centric boot — device_run is the ' +
696
+ 'project-centric build+install+launch.',
697
+ parameters: {
698
+ avd: { type: 'string', description: 'AVD name to boot.' },
699
+ timeout_ms: { type: 'integer', description: 'Max wait for boot in ms (default 180000, max 600000).' },
700
+ },
701
+ output: {
702
+ schema: {
703
+ type: 'object',
704
+ additionalProperties: false,
705
+ properties: {
706
+ serial: { type: 'string', required: true },
707
+ avd: { type: 'string', required: true },
708
+ booted: { type: 'boolean', required: true },
709
+ alreadyRunning: { type: 'boolean', required: true },
710
+ },
711
+ },
712
+ render: (_args, value) => {
713
+ const v = value ?? { serial: '', avd: '', booted: false, alreadyRunning: false }
714
+ return [{ type: 'text', text: `${v.alreadyRunning ? 'Adopted running' : 'Booted'} emulator ${v.serial} (AVD ${v.avd}), boot completed: ${v.booted}` }]
715
+ },
716
+ },
717
+ async execute(args) {
718
+ const avd = String(args.avd ?? '').trim()
719
+ if (avd === '') throw new Error('device_boot requires an avd name (see device_status.avds).')
720
+ const timeout = Math.min(Math.max(args.timeout_ms ?? 180_000, 5_000), 600_000)
721
+ const rows = await DeviceBuild.devices()
722
+ for (const row of rows.filter((item) => item.state === 'device' && item.serial.startsWith('emulator-'))) {
723
+ const name = await DeviceBuild.avdName(row.serial).catch(() => undefined)
724
+ if (name === avd) {
725
+ const booted = await DeviceBuild.waitForBoot(row.serial, timeout)
726
+ return { serial: row.serial, avd, booted, alreadyRunning: true }
727
+ }
728
+ }
729
+ if (!DeviceBuild.emulatorBinary()) throw new Error('No SDK emulator binary found; cannot boot an AVD.')
730
+ const before = new Set(rows.map((item) => item.serial))
731
+ if (!DeviceBuild.bootEmulator(avd)) throw new Error(`Could not launch the emulator for AVD "${avd}".`)
732
+ const deadline = Date.now() + timeout
733
+ let serial
734
+ while (Date.now() < deadline) {
735
+ const now = await DeviceBuild.devices()
736
+ const fresh = now.find((item) => item.serial.startsWith('emulator-') && !before.has(item.serial))
737
+ if (fresh) { serial = fresh.serial; break }
738
+ await new Promise((resolve) => setTimeout(resolve, 1000))
739
+ }
740
+ if (!serial) throw new Error(`No emulator serial appeared for "${avd}" within ${timeout} ms.`)
741
+ const booted = await DeviceBuild.waitForBoot(serial, Math.max(1000, deadline - Date.now()))
742
+ if (!booted) throw new Error(`Emulator ${serial} ("${avd}") did not finish booting within ${timeout} ms.`)
743
+ return { serial, avd, booted: true, alreadyRunning: false }
744
+ },
745
+ })
746
+ }
747
+
748
+ function deviceShutdownTool() {
749
+ return defineTool({
750
+ name: 'device_shutdown',
751
+ description: 'Shut down an emulator (`adb emu kill`). Refuses physical devices — adb has no power-off verb for phones.',
752
+ parameters: {
753
+ serial: { type: 'string', description: 'Android device serial. Omit to use the first attached device.' },
754
+ },
755
+ output: {
756
+ schema: {
757
+ type: 'object',
758
+ additionalProperties: false,
759
+ properties: {
760
+ serial: { type: 'string', required: true },
761
+ shutdown: { type: 'boolean', required: true },
762
+ },
763
+ },
764
+ render: (_args, value) => [{ type: 'text', text: `Shut down emulator ${value?.serial}` }],
765
+ },
766
+ async execute(args) {
767
+ const serial = await requireAndroidDevice(args.serial)
768
+ const isEmulator = serial.startsWith('emulator-') || (await DeviceBuild.avdName(serial).catch(() => undefined)) !== undefined
769
+ if (!isEmulator) {
770
+ 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.`)
771
+ }
772
+ await DeviceBuild.exec(DeviceBuild.adb(), ['-s', serial, 'emu', 'kill']).exit
773
+ return { serial, shutdown: true }
774
+ },
775
+ })
776
+ }
777
+
778
+ const DEVICE_ACTIONS = {
779
+ notifications: ['cmd', 'statusbar', 'expand-notifications'],
780
+ quick_settings: ['cmd', 'statusbar', 'expand-settings'],
781
+ collapse: ['cmd', 'statusbar', 'collapse'],
782
+ lock: ['input', 'keyevent', '223'],
783
+ wake: ['input', 'keyevent', '224'],
784
+ assistant: ['am', 'start', '-a', 'android.intent.action.ASSIST'],
785
+ }
786
+
787
+ function deviceActionTool() {
788
+ return defineTool({
789
+ name: 'device_action',
790
+ description: 'Device-level actions beyond touches: open the notification shade or quick settings, collapse the shade, ' +
791
+ 'lock or wake the screen, launch the assistant, or rotate the display (cycles 0→90→180→270 and pins auto-rotate off).',
792
+ parameters: {
793
+ serial: { type: 'string', description: 'Android device serial. Omit to use the first attached device.' },
794
+ action: { type: 'string', enum: [...Object.keys(DEVICE_ACTIONS), 'rotate'], description: 'Which device action to perform.' },
795
+ },
796
+ output: {
797
+ schema: {
798
+ type: 'object',
799
+ additionalProperties: false,
800
+ properties: {
801
+ serial: { type: 'string', required: true },
802
+ action: { type: 'string', required: true },
803
+ rotation: { type: 'integer' },
804
+ },
805
+ },
806
+ render: (_args, value) => [{ type: 'text', text: value?.rotation !== undefined ? `${value.action} on ${value.serial} → rotation ${value.rotation * 90}°` : `${value?.action} on ${value?.serial}` }],
807
+ },
808
+ async execute(args) {
809
+ const serial = await requireAndroidDevice(args.serial)
810
+ const action = String(args.action ?? '')
811
+ if (action === 'rotate') {
812
+ const current = Number(await DeviceBuild.capture(DeviceBuild.adb(), ['-s', serial, 'shell', 'settings', 'get', 'system', 'user_rotation']))
813
+ const next = ((Number.isFinite(current) ? current : 0) + 1) % 4
814
+ await DeviceBuild.exec(DeviceBuild.adb(), ['-s', serial, 'shell', 'settings', 'put', 'system', 'accelerometer_rotation', '0']).exit
815
+ await DeviceBuild.exec(DeviceBuild.adb(), ['-s', serial, 'shell', 'settings', 'put', 'system', 'user_rotation', String(next)]).exit
816
+ return { serial, action, rotation: next }
817
+ }
818
+ const shell = DEVICE_ACTIONS[action]
819
+ if (!shell) throw new Error(`unknown action "${action}" — use ${[...Object.keys(DEVICE_ACTIONS), 'rotate'].join(', ')}.`)
820
+ await DeviceBuild.exec(DeviceBuild.adb(), ['-s', serial, 'shell', ...shell]).exit
821
+ return { serial, action }
822
+ },
823
+ })
824
+ }
825
+
826
+ function deviceAppsTool() {
827
+ return defineTool({
828
+ name: 'device_apps',
829
+ description: 'List installed Android packages (third-party by default; include_system=true adds platform apps). ' +
830
+ 'Use it to find the real package name before device_launch_app — never guess one.',
831
+ parameters: {
832
+ serial: { type: 'string', description: 'Android device serial. Omit to use the first attached device.' },
833
+ include_system: { type: 'boolean', description: 'Include system/platform packages (default false).' },
834
+ },
835
+ output: {
836
+ schema: {
837
+ type: 'object',
838
+ additionalProperties: false,
839
+ properties: {
840
+ serial: { type: 'string', required: true },
841
+ count: { type: 'integer', required: true },
842
+ packages: { type: 'array', required: true, items: { type: 'string' } },
843
+ },
844
+ },
845
+ render: (_args, value) => {
846
+ const v = value ?? { serial: '', count: 0, packages: [] }
847
+ const shown = v.packages.slice(0, 60).join('\n ')
848
+ return [{ type: 'text', text: `${v.serial}: ${v.count} packages\n ${shown}${v.count > 60 ? `\n … and ${v.count - 60} more` : ''}` }]
849
+ },
850
+ },
851
+ async execute(args) {
852
+ const serial = await requireAndroidDevice(args.serial)
853
+ const output = await DeviceBuild.capture(DeviceBuild.adb(), ['-s', serial, 'shell', 'pm', 'list', 'packages', ...(args.include_system ? [] : ['-3'])])
854
+ const packages = output
855
+ .split(/\r?\n/)
856
+ .map((line) => line.trim())
857
+ .filter((line) => line.startsWith('package:'))
858
+ .map((line) => line.slice('package:'.length).trim())
859
+ return { serial, count: packages.length, packages: packages.slice(0, 200) }
860
+ },
861
+ })
862
+ }
863
+
864
+ function deviceLaunchAppTool() {
865
+ return defineTool({
866
+ name: 'device_launch_app',
867
+ description: 'Launch an installed app by package name (or a unique substring of it). relaunch=true force-stops it first ' +
868
+ 'for a cold start. Resolves the exact package via `pm list packages` so a wrong guess fails loudly instead of ' +
869
+ 'opening the wrong app.',
870
+ parameters: {
871
+ serial: { type: 'string', description: 'Android device serial. Omit to use the first attached device.' },
872
+ package: { type: 'string', description: 'Package name or a unique substring of it.' },
873
+ relaunch: { type: 'boolean', description: 'Force-stop the app first (cold start).' },
874
+ },
875
+ output: {
876
+ schema: {
877
+ type: 'object',
878
+ additionalProperties: false,
879
+ properties: {
880
+ serial: { type: 'string', required: true },
881
+ package: { type: 'string', required: true },
882
+ launched: { type: 'boolean', required: true },
883
+ },
884
+ },
885
+ render: (_args, value) => [{ type: 'text', text: `Launched ${value?.package} on ${value?.serial}` }],
886
+ },
887
+ async execute(args) {
888
+ const serial = await requireAndroidDevice(args.serial)
889
+ let pkg = String(args.package ?? '').trim()
890
+ if (pkg === '') throw new Error('device_launch_app requires a package name (or a unique substring).')
891
+ const listOut = await DeviceBuild.capture(DeviceBuild.adb(), ['-s', serial, 'shell', 'pm', 'list', 'packages'])
892
+ const all = listOut
893
+ .split(/\r?\n/)
894
+ .map((line) => line.trim())
895
+ .filter((line) => line.startsWith('package:'))
896
+ .map((line) => line.slice('package:'.length).trim())
897
+ if (!all.includes(pkg)) {
898
+ const matches = all.filter((item) => item.toLowerCase().includes(pkg.toLowerCase()))
899
+ if (matches.length === 0) throw new Error(`No installed package matches "${pkg}". Run device_apps to list them.`)
900
+ if (matches.length > 1) throw new Error(`"${pkg}" matches ${matches.length} packages (${matches.slice(0, 8).join(', ')}${matches.length > 8 ? ', …' : ''}) — be more specific.`)
901
+ pkg = matches[0]
902
+ }
903
+ if (args.relaunch) await DeviceBuild.exec(DeviceBuild.adb(), ['-s', serial, 'shell', 'am', 'force-stop', pkg]).exit
904
+ const code = await DeviceBuild.exec(DeviceBuild.adb(), ['-s', serial, 'shell', 'monkey', '-p', pkg, '-c', 'android.intent.category.LAUNCHER', '1']).exit
905
+ if (code !== 0) throw new Error(`Could not launch ${pkg} (no launcher activity, or monkey failed with exit ${code}).`)
906
+ return { serial, package: pkg, launched: true }
907
+ },
908
+ })
909
+ }
910
+
602
911
  function deviceScreenTool(engine) {
603
912
  return defineTool({
604
913
  name: 'device_screen',
@@ -713,10 +1022,150 @@ function deviceScreenTool(engine) {
713
1022
 
714
1023
  async function captureScreenSize(serial) {
715
1024
  const output = await DeviceBuild.capture(DeviceBuild.adb(), ['-s', serial, 'shell', 'wm', 'size'])
716
- const match = /Physical size:\s*(\d+)x(\d+)/.exec(output)
1025
+ // An `wm size` override wins over the physical panel — the input space is the override.
1026
+ const override = /Override size:\s*(\d+)x(\d+)/.exec(output)
1027
+ const match = override ?? /Physical size:\s*(\d+)x(\d+)/.exec(output)
717
1028
  return match ? { width: Number(match[1]), height: Number(match[2]) } : undefined
718
1029
  }
719
1030
 
1031
+ /** Recursive node schema is not expressible here; tree children stay open objects. */
1032
+ const UI_TREE_ITEM_SCHEMA = { type: 'object', additionalProperties: true }
1033
+
1034
+ function deviceUiTreeTool() {
1035
+ return defineTool({
1036
+ name: 'device_ui_tree',
1037
+ description: 'Dump the foreground Android app\'s uiautomator hierarchy as a compact node tree — type, text, ' +
1038
+ 'contentDesc, resourceId, pixel bounds, enabled/focused/clickable flags. The default observer for UI automation: ' +
1039
+ 'resource-ids are the most stable tap handles. Use device_tap_element to tap by identity instead of guessing ' +
1040
+ 'pixel coordinates. When the tree comes back shallow or empty on a WebView/Compose/canvas, fall back to ' +
1041
+ 'device_screen (OCR reads pixels and needs no idle).',
1042
+ parameters: {
1043
+ serial: { type: 'string', description: 'Android device serial. Omit to use the first attached device.' },
1044
+ max_depth: { type: 'integer', description: 'Maximum hierarchy depth to include (omit for the full tree).' },
1045
+ filter: { type: 'string', description: 'Case-insensitive substring over text/content-desc/resource-id/type; matching nodes and their ancestors are kept.' },
1046
+ },
1047
+ output: {
1048
+ schema: {
1049
+ type: 'object',
1050
+ additionalProperties: false,
1051
+ properties: {
1052
+ serial: { type: 'string', required: true },
1053
+ rotation: { type: 'integer' },
1054
+ nodes: { type: 'integer', required: true },
1055
+ tree: { type: 'array', required: true, items: UI_TREE_ITEM_SCHEMA },
1056
+ truncated: { type: 'boolean' },
1057
+ },
1058
+ },
1059
+ render: (_args, value) => {
1060
+ const v = value ?? { serial: '', nodes: 0, tree: [] }
1061
+ const lines = [`Device ${v.serial}: ${v.nodes} nodes${v.truncated ? ' (truncated at 40 KB — narrow with max_depth or filter)' : ''}${v.rotation !== undefined ? `, rotation=${v.rotation}` : ''}`]
1062
+ const walk = (nodes, indent) => {
1063
+ for (const node of nodes) {
1064
+ const label = [node.text, node.contentDesc].find(Boolean) ?? ''
1065
+ const id = node.resourceId ? ` [${node.resourceId}]` : ''
1066
+ const flags = []
1067
+ if (node.enabled === false) flags.push('disabled')
1068
+ if (node.clickable) flags.push('clickable')
1069
+ if (node.scrollable) flags.push('scrollable')
1070
+ const b = node.bounds
1071
+ lines.push(`${indent}- ${node.type}${label ? ` "${label}"` : ''}${id}${flags.length > 0 ? ` (${flags.join(',')})` : ''} @${b.x},${b.y} ${b.w}x${b.h}`)
1072
+ if (indent.length < 8) walk(node.children, indent + ' ')
1073
+ }
1074
+ }
1075
+ walk(v.tree, ' ')
1076
+ if (v.nodes === 0) lines.push(' (no nodes — the surface may expose no accessibility; try device_screen OCR)')
1077
+ return [{ type: 'text', text: lines.join('\n') }]
1078
+ },
1079
+ },
1080
+ async execute(args) {
1081
+ const serial = await requireAndroidDevice(args.serial)
1082
+ const parsed = await UiTree.readUiTree(serial)
1083
+ const { tree, count } = UiTree.buildCompactTree(parsed.roots, args.max_depth, args.filter)
1084
+ const capped = UiTree.capTreeToBytes(tree)
1085
+ const out = {
1086
+ serial,
1087
+ nodes: count,
1088
+ tree: capped.tree,
1089
+ ...(capped.truncated ? { truncated: true } : {}),
1090
+ ...(parsed.rotation !== undefined ? { rotation: parsed.rotation } : {}),
1091
+ }
1092
+ return out
1093
+ },
1094
+ })
1095
+ }
1096
+
1097
+ function deviceTapElementTool() {
1098
+ return defineTool({
1099
+ name: 'device_tap_element',
1100
+ description: 'Tap an Android UI element by identity — resource_id matches the node\'s resource-id; text matches its ' +
1101
+ 'text or content-desc. Exact match first, then case-insensitive substring; nested duplicates collapse to one ' +
1102
+ 'target and an ambiguous match lists up to 8 candidates instead of picking one. Disabled or off-screen elements ' +
1103
+ 'are refused with the fix. Pass expect_text / expect_gone and the tap plus its verification become one round trip.',
1104
+ parameters: {
1105
+ serial: { type: 'string', description: 'Android device serial. Omit to use the first attached device.' },
1106
+ resource_id: { type: 'string', description: 'The resource-id to match (e.g. com.android.settings:id/search_bar).' },
1107
+ text: { type: 'string', description: 'Text or content-desc to match. Exact wins over substring.' },
1108
+ expect_text: { type: 'string', description: 'After the tap, re-dump and verify this text is present.' },
1109
+ expect_gone: { type: 'string', description: 'After the tap, re-dump and verify this text is gone.' },
1110
+ allow_offscreen: { type: 'boolean', description: 'Tap a node whose bounds lie outside the screen anyway (default false).' },
1111
+ },
1112
+ output: {
1113
+ schema: {
1114
+ type: 'object',
1115
+ additionalProperties: false,
1116
+ properties: {
1117
+ serial: { type: 'string', required: true },
1118
+ tapped: { type: 'string', required: true },
1119
+ matchedBy: { type: 'string', required: true },
1120
+ x: { type: 'integer', required: true },
1121
+ y: { type: 'integer', required: true },
1122
+ expected: {
1123
+ type: 'object',
1124
+ additionalProperties: false,
1125
+ properties: {
1126
+ mode: { type: 'string', required: true },
1127
+ text: { type: 'string', required: true },
1128
+ matched: { type: 'boolean', required: true },
1129
+ },
1130
+ },
1131
+ },
1132
+ },
1133
+ render: (_args, value) => {
1134
+ const v = value ?? { serial: '', tapped: '', matchedBy: '', x: 0, y: 0 }
1135
+ const lines = [`Tapped ${v.tapped} (${v.matchedBy}) at ${v.x},${v.y} on ${v.serial}`]
1136
+ if (v.expected) lines.push(`Expect ${v.expected.mode} "${v.expected.text}": ${v.expected.matched ? 'VERIFIED' : 'NOT matched — the action did not land as expected'}`)
1137
+ return [{ type: 'text', text: lines.join('\n') }]
1138
+ },
1139
+ },
1140
+ async execute(args) {
1141
+ const serial = await requireAndroidDevice(args.serial)
1142
+ const parsed = await UiTree.readUiTree(serial)
1143
+ const selector = { identifier: args.resource_id, label: args.text }
1144
+ const { node, matchedBy } = UiTree.resolveTapTarget(parsed.roots, selector, { tool: 'device_tap_element', allowOffscreen: args.allow_offscreen === true })
1145
+ const center = UiTree.boundsCenter(node.bounds)
1146
+ await DeviceBuild.exec(DeviceBuild.adb(), ['-s', serial, 'shell', 'input', 'tap', String(center.x), String(center.y)]).exit
1147
+ const describe = () => {
1148
+ const parts = []
1149
+ if (node.resourceId) parts.push(`resource_id ${node.resourceId}`)
1150
+ if (node.text) parts.push(`text ${JSON.stringify(node.text)}`)
1151
+ if (node.contentDesc) parts.push(`content-desc ${JSON.stringify(node.contentDesc)}`)
1152
+ return parts.join(', ') || node.type
1153
+ }
1154
+ const out = { serial, tapped: describe(), matchedBy, x: center.x, y: center.y }
1155
+ const mode = args.expect_gone !== undefined && args.expect_gone !== '' ? 'gone' : (args.expect_text !== undefined && args.expect_text !== '' ? 'appear' : undefined)
1156
+ if (mode !== undefined) {
1157
+ const text = mode === 'gone' ? String(args.expect_gone) : String(args.expect_text)
1158
+ await new Promise((resolve) => setTimeout(resolve, 600))
1159
+ const fresh = await UiTree.readUiTree(serial).catch(() => undefined)
1160
+ const labels = fresh ? UiTree.collectLabels(fresh.roots) : []
1161
+ const matched = mode === 'gone' ? !labels.some((label) => label.toLowerCase().includes(text.toLowerCase())) : labels.some((label) => label.toLowerCase().includes(text.toLowerCase()))
1162
+ out.expected = { mode, text, matched }
1163
+ }
1164
+ return out
1165
+ },
1166
+ })
1167
+ }
1168
+
720
1169
  function deviceLogTool(engine) {
721
1170
  return defineTool({
722
1171
  name: 'device_log',
@@ -898,8 +1347,19 @@ function guidance() {
898
1347
  ' Pass directory when the project root is not the working directory. A run takes minutes; use a generous timeout.',
899
1348
  '- device_screen: capture the attached Android screen as a PNG plus the UI hierarchy and OCR text, so you can see what',
900
1349
  ' the app shows and tap by pixel coordinates. Call it after a run to confirm the app rendered, and to drive UI flows.',
1350
+ '- device_ui_tree: the default screen observer — the uiautomator hierarchy as a typed node tree with resource-ids,',
1351
+ ' text, and pixel bounds. Narrow with filter/max_depth; on a textless surface (WebView, Compose, canvas) use device_screen.',
1352
+ '- device_tap_element: tap a control by resource_id or text/content-desc, with one-call verification via',
1353
+ ' expect_text / expect_gone (no separate screenshot needed to know the tap landed).',
1354
+ '- device_wait_for: wait for text to appear/disappear (polls the UI tree, falls back to OCR on textless surfaces);',
1355
+ ' a timeout is a normal matched:false result, never an error. One call replaces an agent-side poll loop.',
901
1356
  '- device_input: tap/swipe/type/press on the attached Android device at ABSOLUTE pixel coordinates (take the center of a',
902
- ' device_screen box: x=(x1+x2)/2, y=(y1+y2)/2). The control loop is device_screendevice_input device_screen.',
1357
+ ' device_screen box: x=(x1+x2)/2, y=(y1+y2)/2). The control loop is device_ui_treedevice_tap_element, falling back to',
1358
+ ' device_screen → device_input when a surface exposes no accessibility tree. Typing is ASCII over plain adb; non-ASCII',
1359
+ ' (CJK, emoji) goes through the ADBKeyboard IME when installed, and is refused with the install hint otherwise.',
1360
+ '- device_action: notifications / quick_settings / collapse / lock / wake / assistant / rotate.',
1361
+ '- device_boot / device_shutdown: boot an AVD by name and wait for boot / shut an emulator down (refuses physical devices).',
1362
+ '- device_apps / device_launch_app: list installed packages (never guess a package name) / launch one by package or unique substring.',
903
1363
  '- device_log: read device logs (logcat main/crash/events, kernel dmesg). Call it when a run fails or an app misbehaves.',
904
1364
  '- device_status: one normalized snapshot of attached devices, AVDs, running/parked projects, Metro and preview servers.',
905
1365
  '',
@@ -914,6 +1374,14 @@ export function apply(ctx, config) {
914
1374
  Setup.ensureHome()
915
1375
  Setup.writeOcrReadme()
916
1376
 
1377
+ // The playbook skill is host-independent; register it once at apply-time.
1378
+ let disposeSkill = () => {}
1379
+ try {
1380
+ disposeSkill = registerMobileSkill(ctx) ?? (() => {})
1381
+ } catch {
1382
+ // The skills service is optional; the plugin works without the playbook.
1383
+ }
1384
+
917
1385
  const resolve = () => ({
918
1386
  enabled: config?.enabled ?? true,
919
1387
  announceToAgent: config?.announceToAgent ?? true,
@@ -959,6 +1427,14 @@ export function apply(ctx, config) {
959
1427
  deviceRunTool(engine, config),
960
1428
  deviceDetectTool(engine, config),
961
1429
  deviceScreenTool(engine),
1430
+ deviceUiTreeTool(),
1431
+ deviceTapElementTool(),
1432
+ deviceWaitForTool(),
1433
+ deviceBootTool(),
1434
+ deviceShutdownTool(),
1435
+ deviceActionTool(),
1436
+ deviceAppsTool(),
1437
+ deviceLaunchAppTool(),
962
1438
  deviceLogTool(engine),
963
1439
  deviceStatusTool(engine),
964
1440
  deviceInputTool(),
@@ -968,6 +1444,7 @@ export function apply(ctx, config) {
968
1444
  }
969
1445
 
970
1446
  ctx.effect(() => () => {
1447
+ disposeSkill()
971
1448
  void engine.dispose()
972
1449
  }, 'dsh-mobilecode: engine')
973
1450
 
package/lib/skill.js ADDED
@@ -0,0 +1,104 @@
1
+ /**
2
+ * dsh-mobilecode — the plugin's bundled playbook, contributed through
3
+ * ctx.skills.register().
4
+ *
5
+ * A tool description answers "what does this argument mean", one tool at a
6
+ * time. What agents re-derive every session is the WORKFLOW between the tools
7
+ * — which observer to reach for, how to confirm an action landed, what a
8
+ * device refuses to tell you. This skill carries that workflow so the model
9
+ * loads it once per UI task instead of rediscovering it by trial and error.
10
+ *
11
+ * Registration is DEFENSIVE: a host without the skill service still loads the
12
+ * plugin, it just does not advertise the playbook (the scoped inject never
13
+ * runs when the service is absent).
14
+ *
15
+ * Playbook design credit: ZSeven-W/dsh-android (MIT) src/skill.ts.
16
+ */
17
+
18
+ export const SKILL_NAME = "device-ui-automation"
19
+
20
+ export const SKILL_DESCRIPTION =
21
+ "Drive an attached Android device through the dsh-mobilecode device_* tools: read the screen, tap by identity, " +
22
+ "type, and confirm that an action landed. Load this before the first device_ui_tree / device_tap_element call of a UI task."
23
+
24
+ export const SKILL_WHEN_TO_USE =
25
+ "Any task that operates an Android app through the device_* tools — opening apps, tapping controls, filling " +
26
+ "fields, scrolling, reading logs, or verifying what is on screen, on an emulator or a real device."
27
+
28
+ export const SKILL_CONTENT = `# Driving Android with dsh-mobilecode
29
+
30
+ The loop is **observe once → act with an assertion → observe again only if the assertion could not settle it**. Everything goes through adb; an emulator and a USB phone take exactly the same tools with the same arguments.
31
+
32
+ ## Reading the screen
33
+
34
+ | Tool | Cost | Use it for |
35
+ | --- | --- | --- |
36
+ | \`device_ui_tree\` | ~0.6–1.5 s | hierarchy, resource-ids, text, enabled state — the default observer |
37
+ | \`device_screen\` | ~0.3 s + OCR | the picture for the user, plus PaddleOCR text+boxes when the tree is blind |
38
+ | \`device_log\` | fast | what the device logs (main/crash/events buffers) |
39
+
40
+ - Start with \`device_ui_tree\`: the accessibility tree carries \`resource-id\` (e.g. \`com.android.settings:id/search_bar\`), the most stable handle a control can have — far better than its text, which changes with the device language.
41
+ - \`uiautomator\` dumps a snapshot of the CURRENT frame. If an animation is still running the tree can be a half-finished layout; when a read looks wrong, re-read once rather than reasoning about the wrong frame.
42
+ - A shallow or empty tree is NEVER evidence that an app lacks accessibility support. Attribute an unlabeled read to ONE of three causes — (a) the depth/filter cut it: re-read wider; (b) a WebView, Compose or canvas surface that publishes little: \`device_screen\` OCR is the fallback; (c) a DEEP, unfiltered read with no labels — only then may "little accessibility information" be reported. Never jump from a shallow read to "OCR the screen".
43
+ - The uiautomator dump fails on a continuously animating foreground (a page still loading) — the error says so. Do not retry; use \`device_screen\` (OCR reads pixels and needs no idle).
44
+
45
+ ## Acting
46
+
47
+ - Prefer \`device_tap_element\` (resource-id / text / content-desc). Raw pixel coordinates through \`device_input action=tap\` are the last resort — they break on the next layout change.
48
+ - When you must tap pixels, take the center of a box from \`device_ui_tree\`/\`device_screen\`: x=(x1+x2)/2, y=(y1+y2)/2. Coordinates are ABSOLUTE pixels of the current display.
49
+ - \`device_input action=swipe\` scrolls; \`action=key\` with "back" is a first-class verb on Android — use it instead of hunting for an on-screen back arrow.
50
+ - Typing is ASCII-only through adb. Non-ASCII text (Chinese, emoji) cannot be delivered by \`input text\` — do not retry; type via the app's own UI instead.
51
+
52
+ ## Never guess a package name or a control
53
+
54
+ - A package name that looks plausible is routinely NOT the installed one. Check \`device_status\` / \`device_log filter=<package>\` for what is actually running before assuming.
55
+ - Icon-only controls carry no OCR text by definition: the tree's \`content-desc\` is the only reliable handle — a bare tree means "look deeper", never "start guessing".
56
+
57
+ ## Confirming an action landed
58
+
59
+ - Pass \`expect_text\` (or \`expect_gone\`) to \`device_tap_element\`: the tap and its verification become ONE round trip, and the result carries \`expected.matched\`.
60
+ - Waiting for something slow (a load, an animation, a network round trip) is a \`device_ui_tree\`/\`device_screen\` re-read after a pause — one observation, not a poll loop.
61
+ - Never compare screenshots or count pixels to decide whether something happened. Read the text back.
62
+
63
+ ## Logs
64
+
65
+ - \`device_log\` buffers: \`main\` (default), \`crash\` (holds ONLY fatal Java/native crashes — that is where a stack trace lives), \`events\` (activity lifecycle), \`kernel\` (needs adb root; emulators usually allow it).
66
+ - An idle emulator emits hundreds of lines a second. Narrow BEFORE widening: \`filter\` (package or tag substring) and a small \`lines\` count first.
67
+
68
+ ## Real devices
69
+
70
+ - **Every tap on a real phone has real consequences** — posts, likes, purchases, messages. NEVER tap an unidentified control to find out what it does. If a control cannot be identified (no resource-id after a deep tree, no distinguishing text), STOP and report what you see and ask how to proceed. Do not guess coordinates on someone's live account.
71
+ - A phone must be UNLOCKED for anything to be visible; \`device_input action=key key="wakeup"\` wakes it, but a PIN/pattern lock cannot be passed from here.
72
+ - \`device_status\` reports each device's adb state: \`unauthorized\` means the USB-debugging prompt has not been accepted ON the device — no tool can fix that from this side.
73
+ `
74
+
75
+ /**
76
+ * Register the playbook when the host provides the skill service. The scoped
77
+ * inject is the optional-service pattern: a profile without the skill service
78
+ * simply never runs the callback, and the plugin loads without the playbook.
79
+ */
80
+ export function registerMobileSkill(ctx) {
81
+ if (typeof ctx.inject === "function") {
82
+ const fiber = ctx.inject(["skills"], (skillCtx) => {
83
+ skillCtx.effect(() => skillCtx.skills.register({
84
+ name: SKILL_NAME,
85
+ description: SKILL_DESCRIPTION,
86
+ whenToUse: SKILL_WHEN_TO_USE,
87
+ content: SKILL_CONTENT,
88
+ source: "bundled",
89
+ }), "dsh-mobilecode: skill")
90
+ })
91
+ return () => { try { fiber.dispose() } catch { /* already disposed */ } }
92
+ }
93
+ // Very old hosts: direct registration when the service exists, else skip.
94
+ if (ctx.skills && typeof ctx.skills.register === "function") {
95
+ return ctx.skills.register({
96
+ name: SKILL_NAME,
97
+ description: SKILL_DESCRIPTION,
98
+ whenToUse: SKILL_WHEN_TO_USE,
99
+ content: SKILL_CONTENT,
100
+ source: "bundled",
101
+ })
102
+ }
103
+ return undefined
104
+ }