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/README.md +34 -1
- package/lib/device-build.js +60 -1
- package/lib/index.js +482 -5
- package/lib/skill.js +104 -0
- package/lib/uitree.js +638 -0
- package/package.json +1 -1
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
|
-
|
|
585
|
-
|
|
586
|
-
|
|
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
|
-
|
|
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
|
|
1357
|
+
' device_screen box: x=(x1+x2)/2, y=(y1+y2)/2). The control loop is device_ui_tree → device_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
|
+
}
|