dsh-mobilecode 0.7.1 → 0.8.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
@@ -24,6 +24,7 @@ import * as StreamAccess from './stream-access.js'
24
24
  import { AndroidStreamHost, ROTATION_CYCLE } from './android-stream.js'
25
25
  import * as Vision from './vision.js'
26
26
  import { DevicePreviewEngine } from './device-preview.js'
27
+ import { MeshHub } from './mesh-hub.js'
27
28
  import * as Setup from './setup.js'
28
29
  import { registerMobileSkill } from './skill.js'
29
30
  import { existsSync, readFileSync } from 'node:fs'
@@ -92,6 +93,38 @@ function makeRoutes(engine, config, stream) {
92
93
  if ((req.method ?? 'GET') !== 'POST') { writeJson(res, 405, { error: 'method not allowed' }); return false }
93
94
  return true
94
95
  }
96
+ // Mesh fence: game clients are plain HTTP stacks (OkHttp/Unity/curl) that
97
+ // never send browser headers — they must only prove a loopback peer plus a
98
+ // Host the emulator NAT actually presents (10.0.2.2) or loopback. Anything
99
+ // that DOES look like a browser request (Origin / Sec-Fetch-*) additionally
100
+ // passes the trusted-browser stream fence, so a random web page can never
101
+ // join a game session or read its messages.
102
+ const meshGuard = (req, res) => {
103
+ if (!StreamAccess.isLoopbackRemoteAddress(req.socket?.remoteAddress)) {
104
+ writeJson(res, 403, { code: 'forbidden', error: 'the mesh is loopback-only (emulators reach it via 10.0.2.2)' })
105
+ return false
106
+ }
107
+ if (req.headers.origin !== undefined || req.headers['sec-fetch-site'] !== undefined || req.headers['sec-fetch-mode'] !== undefined) {
108
+ return fence(req, res, false)
109
+ }
110
+ let hostname = ''
111
+ try { hostname = new URL('http://' + String(req.headers.host ?? '')).hostname } catch { hostname = '' }
112
+ const trusted = hostname === 'localhost' || hostname === '::1' || hostname === '[::1]'
113
+ || hostname.startsWith('127.') || hostname === '10.0.2.2' || hostname === '10.0.3.2'
114
+ if (!trusted) { writeJson(res, 403, { code: 'forbidden', error: `unexpected Host "${hostname}" for a mesh client` }); return false }
115
+ return true
116
+ }
117
+ const meshAuth = (req, url, body) => {
118
+ const id = body?.id ?? url.searchParams.get('id') ?? req.headers['x-mesh-peer']
119
+ const token = body?.token ?? url.searchParams.get('token') ?? req.headers['x-mesh-token']
120
+ if (typeof id !== 'string' || !stream.mesh.verify(id, typeof token === 'string' ? token : '')) return undefined
121
+ return stream.mesh.peers.get(id)
122
+ }
123
+ const meshUnauthorized = (res) => writeJson(res, 401, { code: 'token_invalid', error: 'POST /mesh/join first, then pass your id + token' })
124
+ const meshFail = (res, error) => {
125
+ const status = ({ unknown_peer: 404, unknown_session: 409, not_member: 403, too_large: 413 })[error?.code] ?? 400
126
+ writeJson(res, status, { code: error?.code ?? 'bad_request', error: error instanceof Error ? error.message : String(error) })
127
+ }
95
128
  const platformOf = (value) => (value === 'ios' || value === 'android' ? value : undefined)
96
129
  const routes = [
97
130
  // GET /api/dsh-mobilecode?directory=... → current info (platforms, servers, builds, bundler).
@@ -590,6 +623,118 @@ function makeRoutes(engine, config, stream) {
590
623
  }
591
624
  },
592
625
  },
626
+ // ── mesh (v0.8.0) ─ LocalSend-style rendezvous for co-op testing. Emulators
627
+ // cannot multicast through their isolated slirp NATs, but every one reaches
628
+ // the host at 10.0.2.2, so the hub lives on these routes. A game joins once
629
+ // (its serial pins a stable random callsign), links with a peer, then sends
630
+ // and polls JSON through the hub — which doubles as the agent's observation
631
+ // window (mesh_log) and network-condition injector (mesh_tune).
632
+ {
633
+ kind: 'exact',
634
+ path: API_BASE + '/mesh/join',
635
+ handler: async (req, res) => {
636
+ if (!meshGuard(req, res)) return
637
+ if (!isPost(req, res)) return
638
+ const body = await readBody(req, res)
639
+ if (body === undefined) return
640
+ try {
641
+ const { peer, token } = stream.mesh.join({ serial: body.serial, name: body.name, role: body.role })
642
+ writeJson(res, 200, { ok: true, peer, token })
643
+ } catch (error) {
644
+ meshFail(res, error)
645
+ }
646
+ },
647
+ },
648
+ {
649
+ kind: 'exact',
650
+ path: API_BASE + '/mesh/peers',
651
+ handler: async (req, res) => {
652
+ if (!meshGuard(req, res)) return
653
+ const url = new URL(req.url ?? '/', 'http://localhost')
654
+ const me = meshAuth(req, url, undefined)
655
+ if (!me) { meshUnauthorized(res); return }
656
+ writeJson(res, 200, { ok: true, me, ...stream.mesh.status() })
657
+ },
658
+ },
659
+ {
660
+ kind: 'exact',
661
+ path: API_BASE + '/mesh/link',
662
+ handler: async (req, res) => {
663
+ if (!meshGuard(req, res)) return
664
+ if (!isPost(req, res)) return
665
+ const body = await readBody(req, res)
666
+ if (body === undefined) return
667
+ const url = new URL(req.url ?? '/', 'http://localhost')
668
+ const me = meshAuth(req, url, body)
669
+ if (!me) { meshUnauthorized(res); return }
670
+ try {
671
+ const queries = Array.isArray(body.with) ? body.with : []
672
+ const session = stream.mesh.link([me.name, ...queries])
673
+ writeJson(res, 200, {
674
+ ok: true,
675
+ session: {
676
+ id: session.id,
677
+ members: session.members.map((id) => stream.mesh.peers.get(id)?.name ?? id),
678
+ policy: session.policy,
679
+ },
680
+ })
681
+ } catch (error) {
682
+ meshFail(res, error)
683
+ }
684
+ },
685
+ },
686
+ {
687
+ kind: 'exact',
688
+ path: API_BASE + '/mesh/send',
689
+ handler: async (req, res) => {
690
+ if (!meshGuard(req, res)) return
691
+ if (!isPost(req, res)) return
692
+ const body = await readBody(req, res)
693
+ if (body === undefined) return
694
+ const url = new URL(req.url ?? '/', 'http://localhost')
695
+ const me = meshAuth(req, url, body)
696
+ if (!me) { meshUnauthorized(res); return }
697
+ try {
698
+ const result = stream.mesh.send({ session: body.session, from: me.id, body: body.body })
699
+ writeJson(res, 200, { ok: true, ...result })
700
+ } catch (error) {
701
+ meshFail(res, error)
702
+ }
703
+ },
704
+ },
705
+ {
706
+ kind: 'exact',
707
+ path: API_BASE + '/mesh/poll',
708
+ handler: async (req, res) => {
709
+ if (!meshGuard(req, res)) return
710
+ const url = new URL(req.url ?? '/', 'http://localhost')
711
+ const me = meshAuth(req, url, undefined)
712
+ if (!me) { meshUnauthorized(res); return }
713
+ const after = Number(url.searchParams.get('after')) || 0
714
+ const wait = Math.min(Number(url.searchParams.get('wait')) || 0, 30_000)
715
+ let result = stream.mesh.poll(me.id, after)
716
+ if (result.messages.length === 0 && wait > 0) {
717
+ await stream.mesh.waitFor(me.id, wait)
718
+ result = stream.mesh.poll(me.id, after)
719
+ }
720
+ writeJson(res, 200, { ok: true, ...result })
721
+ },
722
+ },
723
+ {
724
+ kind: 'exact',
725
+ path: API_BASE + '/mesh/leave',
726
+ handler: async (req, res) => {
727
+ if (!meshGuard(req, res)) return
728
+ if (!isPost(req, res)) return
729
+ const body = await readBody(req, res)
730
+ if (body === undefined) return
731
+ const url = new URL(req.url ?? '/', 'http://localhost')
732
+ const me = meshAuth(req, url, body)
733
+ if (!me) { meshUnauthorized(res); return }
734
+ stream.mesh.leave(me.id)
735
+ writeJson(res, 200, { ok: true })
736
+ },
737
+ },
593
738
  ]
594
739
  return routes
595
740
  }
@@ -1827,6 +1972,576 @@ function deviceMeminfoTool() {
1827
1972
  })
1828
1973
  }
1829
1974
 
1975
+ // ── display, fleet, co-op observation + mesh tools (v0.8.0) ─────────────────
1976
+
1977
+ /** Drop null/undefined fields so strict output schemas stay satisfiable. */
1978
+ function clean(object) {
1979
+ return Object.fromEntries(Object.entries(object).filter(([, value]) => value !== null && value !== undefined))
1980
+ }
1981
+
1982
+ function deviceDisplayTool() {
1983
+ return defineTool({
1984
+ name: 'device_display',
1985
+ description: 'Read or change a device display metrics: DPI (wm density) and pixel resolution (wm size). ' +
1986
+ 'action=get reports physical + current override; action=set applies overrides (density and/or width+height); ' +
1987
+ 'action=reset restores physical values for both. Layouts reflow instantly — re-observe with device_screen ' +
1988
+ 'after a change, and remember input coordinates follow the new resolution.',
1989
+ parameters: {
1990
+ serial: { type: 'string', description: 'Android device serial. Omit to use the first attached device.' },
1991
+ action: { type: 'string', enum: ['get', 'set', 'reset'], description: 'Defaults to get.' },
1992
+ density: { type: 'integer', description: 'DPI to set (action=set), 80..800 — e.g. 420 for a phone, 260 for a tablet-ish look.' },
1993
+ width: { type: 'integer', description: 'Resolution width px to set (action=set, requires height).' },
1994
+ height: { type: 'integer', description: 'Resolution height px to set (action=set, requires width).' },
1995
+ },
1996
+ output: {
1997
+ schema: {
1998
+ type: 'object',
1999
+ additionalProperties: false,
2000
+ properties: {
2001
+ serial: { type: 'string', required: true },
2002
+ action: { type: 'string', required: true },
2003
+ density: {
2004
+ type: 'object',
2005
+ additionalProperties: false,
2006
+ properties: { physical: { type: 'integer' }, override: { type: 'integer' } },
2007
+ },
2008
+ size: {
2009
+ type: 'object',
2010
+ additionalProperties: false,
2011
+ properties: {
2012
+ physical: { type: 'object', additionalProperties: false, properties: { width: { type: 'integer', required: true }, height: { type: 'integer', required: true } } },
2013
+ override: { type: 'object', additionalProperties: false, properties: { width: { type: 'integer', required: true }, height: { type: 'integer', required: true } } },
2014
+ },
2015
+ },
2016
+ applied: { type: 'array', items: { type: 'string' } },
2017
+ },
2018
+ },
2019
+ render: (_args, value) => {
2020
+ const v = value ?? { serial: '', action: 'get' }
2021
+ const d = v.density ?? {}
2022
+ const s = v.size ?? {}
2023
+ const dpi = `DPI ${d.override ?? d.physical ?? '?'}${d.override ? ` (physical ${d.physical}, overridden)` : ''}`
2024
+ const px = s.physical ? `${s.override?.width ?? s.physical.width}x${s.override?.height ?? s.physical.height}${s.override ? ` (physical ${s.physical.width}x${s.physical.height})` : ''}` : 'size unknown'
2025
+ const applied = v.applied?.length ? ` → applied: ${v.applied.join(', ')}` : ''
2026
+ return [{ type: 'text', text: `${v.serial} [${v.action}]: ${dpi} · ${px}${applied}` }]
2027
+ },
2028
+ },
2029
+ async execute(args) {
2030
+ const serial = await requireAndroidDevice(args.serial)
2031
+ const action = args.action ?? 'get'
2032
+ const applied = []
2033
+ if (action === 'set') {
2034
+ const { density, width, height } = args
2035
+ if (density == null && width == null && height == null) throw new Error('action=set needs density and/or width+height (or use get/reset).')
2036
+ if ((width == null) !== (height == null)) throw new Error('setting resolution needs BOTH width and height.')
2037
+ if (density != null && (density < 80 || density > 800)) throw new Error('density must be within 80..800.')
2038
+ if (width != null && (width < 100 || width > 10_000 || height < 100 || height > 10_000)) throw new Error('resolution sides must each be 100..10000 px.')
2039
+ if (width != null) {
2040
+ await DeviceBuild.adbRun(serial, ['shell', 'wm', 'size', `${width}x${height}`])
2041
+ applied.push(`size ${width}x${height}`)
2042
+ }
2043
+ if (density != null) {
2044
+ await DeviceBuild.adbRun(serial, ['shell', 'wm', 'density', String(density)])
2045
+ applied.push(`density ${density}`)
2046
+ }
2047
+ } else if (action === 'reset') {
2048
+ await DeviceBuild.adbRun(serial, ['shell', 'wm', 'density', 'reset'])
2049
+ await DeviceBuild.adbRun(serial, ['shell', 'wm', 'size', 'reset'])
2050
+ applied.push('density reset', 'size reset')
2051
+ }
2052
+ const [densityText, sizeText] = await Promise.all([
2053
+ DeviceBuild.adbRun(serial, ['shell', 'wm', 'density']),
2054
+ DeviceBuild.adbRun(serial, ['shell', 'wm', 'size']),
2055
+ ])
2056
+ return {
2057
+ serial,
2058
+ action,
2059
+ density: clean(DeviceBuild.parseWmDensity(densityText)),
2060
+ size: clean(DeviceBuild.parseWmSize(sizeText)),
2061
+ ...(applied.length ? { applied } : {}),
2062
+ }
2063
+ },
2064
+ })
2065
+ }
2066
+
2067
+ /** Prefer the newest API level when picking a default system image. */
2068
+ function newestImage(images) {
2069
+ const apiOf = (id) => Number(/android-(\d+)/.exec(id)?.[1] ?? 0)
2070
+ return [...images].sort((a, b) => apiOf(a) - apiOf(b)).at(-1)
2071
+ }
2072
+
2073
+ function deviceAvdCreateTool() {
2074
+ return defineTool({
2075
+ name: 'device_avd_create',
2076
+ description: 'Create a second virtual device (the co-op partner) via avdmanager. clone_from copies an existing ' +
2077
+ 'AVD\'s full hardware config (same image, DPI, RAM, SoC) so both players behave identically; otherwise pass an ' +
2078
+ 'explicit image id + device profile. This only creates the AVD — boot it with device_boot (a running emulator ' +
2079
+ 'holds 5554, so device #2 lands on emulator-5556 automatically), then wire the two together with the mesh.',
2080
+ parameters: {
2081
+ name: { type: 'string', description: 'New AVD name (letters/digits/._-, up to 64).' },
2082
+ clone_from: { type: 'string', description: 'Existing AVD whose config.ini to clone (recommended for parity).' },
2083
+ image: { type: 'string', description: 'System-image package id (e.g. system-images;android-35;google_apis;x86_64). Defaults to the clone source\'s image, else the newest installed.' },
2084
+ device: { type: 'string', description: 'avdmanager hardware profile when not cloning (default pixel_7).' },
2085
+ },
2086
+ output: {
2087
+ schema: {
2088
+ type: 'object',
2089
+ additionalProperties: false,
2090
+ properties: {
2091
+ name: { type: 'string', required: true },
2092
+ imageId: { type: 'string', required: true },
2093
+ configPath: { type: 'string', required: true },
2094
+ clonedFrom: { type: 'string' },
2095
+ },
2096
+ },
2097
+ render: (_args, value) => {
2098
+ const v = value ?? { name: '', imageId: '', configPath: '' }
2099
+ return [{ type: 'text', text: `Created AVD "${v.name}" (${v.imageId}${v.clonedFrom ? `, cloned hardware from ${v.clonedFrom}` : ''}) — boot it with device_boot.` }]
2100
+ },
2101
+ },
2102
+ async execute(args) {
2103
+ const name = String(args.name ?? '').trim()
2104
+ if (!/^[A-Za-z0-9._-]{1,64}$/.test(name)) throw new Error('AVD name must match [A-Za-z0-9._-]{1,64}.')
2105
+ if (!DeviceBuild.avdmanagerBinary()) throw new Error('avdmanager not found — install SDK cmdline-tools (sdkmanager --install "cmdline-tools;latest").')
2106
+ const avds = await DeviceBuild.androidAvds()
2107
+ if (avds.includes(name)) throw new Error(`AVD "${name}" already exists — boot it with device_boot or choose another name.`)
2108
+ let cloneConfig
2109
+ let imageId = typeof args.image === 'string' && args.image.includes(';') ? args.image.trim() : undefined
2110
+ if (args.clone_from) {
2111
+ if (!avds.includes(args.clone_from)) throw new Error(`clone_from "${args.clone_from}" is not a known AVD (see device_status.avds).`)
2112
+ try { cloneConfig = readFileSync(DeviceBuild.avdConfigPath(args.clone_from), 'utf8') } catch { throw new Error(`could not read config.ini for "${args.clone_from}".`) }
2113
+ if (!imageId) {
2114
+ const sysdir = /^\s*image\.sysdir\.1\s*=\s*(.+)$/m.exec(cloneConfig)?.[1]?.trim()
2115
+ if (sysdir) imageId = sysdir.replace(/\\/g, '/').replace(/\/+$/, '').split('/').join(';')
2116
+ }
2117
+ }
2118
+ if (!imageId) {
2119
+ const images = await DeviceBuild.installedSystemImages()
2120
+ if (images.length === 0) throw new Error('no system images installed — install one first: sdkmanager --install "system-images;android-35;google_apis;x86_64".')
2121
+ imageId = newestImage(images)
2122
+ }
2123
+ // Always pass -d: without a device profile avdmanager prompts "Do you
2124
+ // wish to create a custom hardware profile?" and crashes on EOF stdin
2125
+ // (Range [0, 0 + -1) out of bounds). Clone parity comes from the
2126
+ // config.ini merge below, not from the base profile.
2127
+ const created = await DeviceBuild.createAvd({ name, imageId, deviceProfile: args.device ?? 'pixel_7', cloneConfigText: cloneConfig })
2128
+ return { name, imageId, configPath: created.configPath, ...(args.clone_from ? { clonedFrom: args.clone_from } : {}) }
2129
+ },
2130
+ })
2131
+ }
2132
+
2133
+ /**
2134
+ * Pure adb-argv builder for one device_batch step (exported for offline tests).
2135
+ * Throws with the offending step named when the parameters do not fit the action.
2136
+ */
2137
+ export function buildInputArgv(step) {
2138
+ const action = step?.action ?? 'tap'
2139
+ const int = (value) => (typeof value === 'number' && Number.isFinite(value) ? Math.round(value) : undefined)
2140
+ switch (action) {
2141
+ case 'tap': {
2142
+ const x = int(step.x)
2143
+ const y = int(step.y)
2144
+ if (x === undefined || y === undefined) throw new Error('step action=tap needs numeric x and y.')
2145
+ return { argv: ['shell', 'input', 'tap', String(x), String(y)], sent: `tap ${x},${y}` }
2146
+ }
2147
+ case 'swipe': {
2148
+ const x = int(step.x)
2149
+ const y = int(step.y)
2150
+ const x2 = int(step.x2)
2151
+ const y2 = int(step.y2)
2152
+ if ([x, y, x2, y2].some((value) => value === undefined)) throw new Error('step action=swipe needs numeric x, y, x2, y2.')
2153
+ const duration = int(step.duration) ?? 200
2154
+ return { argv: ['shell', 'input', 'swipe', String(x), String(y), String(x2), String(y2), String(duration)], sent: `swipe ${x},${y}→${x2},${y2}` }
2155
+ }
2156
+ case 'text': {
2157
+ const text = step.text
2158
+ if (typeof text !== 'string' || text === '') throw new Error('step action=text needs a non-empty text string.')
2159
+ if (!DeviceBuild.isAsciiInput(text)) throw new Error(`batch action=text is ASCII-only (non-ASCII needs the ADBKeyboard path — use device_input): "${text}".`)
2160
+ return { argv: ['shell', 'input', 'text', DeviceBuild.escapeInputText(text)], sent: `text "${text}"` }
2161
+ }
2162
+ case 'key': {
2163
+ const raw = String(step.key ?? '')
2164
+ const code = /^\d+$/.test(raw) ? Number(raw) : KEYCODES[raw.toLowerCase()]
2165
+ if (!code) throw new Error(`unknown key "${raw}" in batch step.`)
2166
+ return { argv: ['shell', 'input', 'keyevent', String(code)], sent: `key ${raw}` }
2167
+ }
2168
+ default:
2169
+ throw new Error(`unknown step action "${action}" — use tap, swipe, text or key.`)
2170
+ }
2171
+ }
2172
+
2173
+ function deviceBatchTool() {
2174
+ return defineTool({
2175
+ name: 'device_batch',
2176
+ description: 'Fire several input actions at once across devices — the co-op primitive for "both players press ' +
2177
+ 'attack on the same frame". All steps dispatch concurrently (separate adb connections, spawned in parallel, so ' +
2178
+ 'cross-device skew is a few milliseconds), each step carries the same fields device_input uses plus its own ' +
2179
+ 'serial (omit serial to target the first device). A failing step never blocks the others; the per-step results ' +
2180
+ 'say what landed. Follow with device_pair_capture to watch what both screens did.',
2181
+ parameters: {
2182
+ steps: {
2183
+ type: 'array',
2184
+ items: {
2185
+ type: 'object',
2186
+ additionalProperties: true,
2187
+ properties: {
2188
+ serial: { type: 'string' },
2189
+ action: { type: 'string', enum: ['tap', 'swipe', 'text', 'key'] },
2190
+ x: { type: 'integer' }, y: { type: 'integer' }, x2: { type: 'integer' }, y2: { type: 'integer' },
2191
+ duration: { type: 'integer' }, text: { type: 'string' }, key: { type: 'string' },
2192
+ },
2193
+ },
2194
+ description: '1..16 input steps, e.g. [{serial:"emulator-5554",action:"tap",x:540,y:1200},{serial:"emulator-5556",action:"key",key:"space"}].',
2195
+ },
2196
+ settle_ms: { type: 'integer', description: 'After the last dispatch, wait this long (0..10000, default 0) so the game can react before you observe.' },
2197
+ },
2198
+ output: {
2199
+ schema: {
2200
+ type: 'object',
2201
+ additionalProperties: false,
2202
+ properties: {
2203
+ ok: { type: 'boolean', required: true },
2204
+ results: {
2205
+ type: 'array',
2206
+ required: true,
2207
+ items: {
2208
+ type: 'object',
2209
+ additionalProperties: false,
2210
+ properties: {
2211
+ index: { type: 'integer', required: true },
2212
+ serial: { type: 'string', required: true },
2213
+ ok: { type: 'boolean', required: true },
2214
+ sent: { type: 'string' },
2215
+ error: { type: 'string' },
2216
+ },
2217
+ },
2218
+ },
2219
+ },
2220
+ },
2221
+ render: (_args, value) => {
2222
+ const v = value ?? { ok: false, results: [] }
2223
+ const lines = v.results.map((r) => ` #${r.index} ${r.serial}: ${r.ok ? r.sent : `FAILED — ${r.error}`}`)
2224
+ return [{ type: 'text', text: `device_batch ${v.ok ? 'all landed' : 'had failures'}:\n${lines.join('\n')}` }]
2225
+ },
2226
+ },
2227
+ async execute(args) {
2228
+ const steps = Array.isArray(args.steps) ? args.steps : []
2229
+ if (steps.length === 0 || steps.length > 16) throw new Error('steps must hold 1..16 input actions.')
2230
+ const planned = steps.map((step, index) => ({ index, step, ...buildInputArgv(step) }))
2231
+ let fallback
2232
+ for (const item of planned) {
2233
+ if (typeof item.step.serial === 'string' && item.step.serial !== '') item.serial = item.step.serial
2234
+ else { fallback ??= await requireAndroidDevice(undefined); item.serial = fallback }
2235
+ }
2236
+ const results = await Promise.all(planned.map(async (item) => {
2237
+ try {
2238
+ await DeviceBuild.adbRun(item.serial, item.argv)
2239
+ return { index: item.index, serial: item.serial, ok: true, sent: item.sent }
2240
+ } catch (error) {
2241
+ return { index: item.index, serial: item.serial, ok: false, error: error instanceof Error ? error.message : String(error) }
2242
+ }
2243
+ }))
2244
+ const settle = Math.min(Math.max(args.settle_ms ?? 0, 0), 10_000)
2245
+ if (settle > 0) await new Promise((resolve) => setTimeout(resolve, settle))
2246
+ return { ok: results.every((r) => r.ok), results }
2247
+ },
2248
+ })
2249
+ }
2250
+
2251
+ /** One device's screen state for device_pair_capture (mirrors device_screen's parts). */
2252
+ async function captureForPair(serial, directory, wantOcr) {
2253
+ const [png, ui, foreground, size] = await Promise.all([
2254
+ DeviceBuild.screenCapture(serial, directory).catch(() => undefined),
2255
+ DeviceBuild.uiDump(serial).catch(() => []),
2256
+ DeviceBuild.foregroundActivity(serial).catch(() => undefined),
2257
+ captureScreenSize(serial).catch(() => undefined),
2258
+ ])
2259
+ const out = { serial, ui, ...(foreground ? { foreground } : {}), ...(png ? { screenshot: png } : {}) }
2260
+ if (png && size) { out.width = size.width; out.height = size.height }
2261
+ if (wantOcr) {
2262
+ if (!png) out.ocrError = 'screenshot failed'
2263
+ else {
2264
+ const ocr = await DeviceBuild.ocrImage(png).catch(() => [])
2265
+ if (ocr.length > 0) out.ocr = ocr
2266
+ else out.ocrError = 'PaddleOCR returned no text'
2267
+ }
2268
+ }
2269
+ return out
2270
+ }
2271
+
2272
+ function devicePairCaptureTool(vision) {
2273
+ return defineTool({
2274
+ name: 'device_pair_capture',
2275
+ description: 'Watch two devices at the same instant: one call captures BOTH screens (PNG + uiautomator digest, ' +
2276
+ 'optional OCR) in parallel and attaches both screenshots as real image blocks, so a vision model sees both ' +
2277
+ 'players in a single glance. This is the co-op observation primitive — call it right after device_batch (or any ' +
2278
+ 'mesh_send) to watch the reaction on both sides.',
2279
+ parameters: {
2280
+ serials: { type: 'array', items: { type: 'string' }, description: 'Exactly two device serials. Defaults to the first two attached devices.' },
2281
+ ocr: { type: 'boolean', description: 'Run PaddleOCR on both screenshots (default false — slower). UI trees usually suffice.' },
2282
+ directory: { type: 'string', description: 'Where to store the PNGs (default: temp).' },
2283
+ },
2284
+ output: {
2285
+ schema: {
2286
+ type: 'object',
2287
+ additionalProperties: false,
2288
+ properties: {
2289
+ devices: {
2290
+ type: 'array',
2291
+ required: true,
2292
+ items: {
2293
+ type: 'object',
2294
+ additionalProperties: false,
2295
+ properties: {
2296
+ serial: { type: 'string', required: true },
2297
+ screenshot: { type: 'string' },
2298
+ width: { type: 'integer' },
2299
+ height: { type: 'integer' },
2300
+ foreground: { type: 'string' },
2301
+ ui: { type: 'array', items: { type: 'object', additionalProperties: false, properties: { text: { type: 'string', required: true }, resourceId: { type: 'string' }, bounds: { type: 'array', items: { type: 'integer' } } } } },
2302
+ ocr: { type: 'array', items: { type: 'object', additionalProperties: false, properties: { text: { type: 'string', required: true }, confidence: { type: 'number' }, box: { type: 'array', items: { type: 'integer' } } } } },
2303
+ ocrError: { type: 'string' },
2304
+ },
2305
+ },
2306
+ },
2307
+ images: { type: 'array', items: Vision.IMAGE_REF_SCHEMA },
2308
+ },
2309
+ },
2310
+ render: (_args, value) => {
2311
+ const v = value ?? { devices: [] }
2312
+ const blocks = [{ type: 'text', text: 'Pair capture (both devices, same instant):' }]
2313
+ for (const device of v.devices) {
2314
+ blocks.push({
2315
+ type: 'text',
2316
+ text: [
2317
+ `▶ ${device.serial}${device.foreground ? ` — foreground: ${device.foreground}` : ''}${device.screenshot ? ` [${device.screenshot}${device.width ? ` ${device.width}x${device.height}` : ''}]` : ''}`,
2318
+ ...(device.ui ?? []).slice(0, 25).map((item) => ` - ${item.text}${item.resourceId ? ` [${item.resourceId}]` : ''}${item.bounds ? ` @${item.bounds.join(',')}` : ''}`),
2319
+ ...((device.ui?.length ?? 0) > 25 ? [` … and ${device.ui.length - 25} more`] : []),
2320
+ ...(device.ocr ?? []).slice(0, 15).map((item) => ` “${item.text}” (${Math.round(item.confidence * 100)}%)`),
2321
+ ...(device.ocrError ? [` OCR unavailable: ${device.ocrError}`] : []),
2322
+ ].join('\n'),
2323
+ })
2324
+ }
2325
+ return Vision.appendImageBlock(blocks, v)
2326
+ },
2327
+ },
2328
+ async execute(args, exec) {
2329
+ let serials = (Array.isArray(args.serials) ? args.serials : []).filter((s) => typeof s === 'string' && s !== '').slice(0, 2)
2330
+ if (serials.length < 2) {
2331
+ const rows = await DeviceBuild.devices()
2332
+ const online = [...new Set([...serials, ...rows.filter((r) => r.state === 'device').map((r) => r.serial)])]
2333
+ serials = online.slice(0, 2)
2334
+ }
2335
+ if (serials.length < 2) throw new Error(`device_pair_capture needs two devices — found ${serials.length}. Boot a second with device_boot, then mesh-link them.`)
2336
+ const wantOcr = args.ocr === true
2337
+ const devices = await Promise.all(serials.map((serial) => captureForPair(serial, args.directory, wantOcr)))
2338
+ const images = (await Promise.all(devices.map((device) => Vision.maybeAttachScreenshot(vision, device.screenshot, exec)))).filter((image) => image !== undefined)
2339
+ return { devices, ...(images.length > 0 ? { images } : {}) }
2340
+ },
2341
+ })
2342
+ }
2343
+
2344
+ const MESH_POLICY_SCHEMA = {
2345
+ type: 'object',
2346
+ additionalProperties: false,
2347
+ properties: {
2348
+ latencyMs: { type: 'integer', required: true },
2349
+ jitterMs: { type: 'integer', required: true },
2350
+ dropPct: { type: 'number', required: true },
2351
+ dupPct: { type: 'number', required: true },
2352
+ throttleKbps: { type: 'integer', required: true },
2353
+ },
2354
+ }
2355
+ const MESH_PEER_SCHEMA = {
2356
+ type: 'object',
2357
+ additionalProperties: false,
2358
+ properties: {
2359
+ id: { type: 'string', required: true },
2360
+ name: { type: 'string', required: true },
2361
+ role: { type: 'string', required: true },
2362
+ joinedAt: { type: 'integer', required: true },
2363
+ lastSeen: { type: 'integer', required: true },
2364
+ serial: { type: 'string' },
2365
+ },
2366
+ }
2367
+ const MESH_SESSION_SCHEMA = {
2368
+ type: 'object',
2369
+ additionalProperties: false,
2370
+ properties: {
2371
+ id: { type: 'string', required: true },
2372
+ members: { type: 'array', items: { type: 'string' }, required: true },
2373
+ policy: MESH_POLICY_SCHEMA,
2374
+ pending: { type: 'integer', required: true },
2375
+ },
2376
+ }
2377
+
2378
+ function meshStatusTool(mesh) {
2379
+ return defineTool({
2380
+ name: 'mesh_status',
2381
+ description: 'Inspect the host mesh hub: every peer that joined from a device (a random callsign pinned to its ' +
2382
+ 'serial, LocalSend-style), every session with its members, live network policy and undelivered mail. Devices ' +
2383
+ 'reach this hub at http://10.0.2.2:<dsh-port>/api/dsh-mobilecode/mesh/* — games POST join {serial?,name?}, ' +
2384
+ 'then link/send/poll with the returned id+token.',
2385
+ parameters: {},
2386
+ output: {
2387
+ schema: {
2388
+ type: 'object',
2389
+ additionalProperties: false,
2390
+ properties: {
2391
+ peers: { type: 'array', items: MESH_PEER_SCHEMA, required: true },
2392
+ sessions: { type: 'array', items: MESH_SESSION_SCHEMA, required: true },
2393
+ messageSeq: { type: 'integer', required: true },
2394
+ },
2395
+ },
2396
+ render: (_args, value) => {
2397
+ const v = value ?? { peers: [], sessions: [], messageSeq: 0 }
2398
+ const lines = [
2399
+ `mesh: ${v.peers.length} peer(s), ${v.sessions.length} session(s), ${v.messageSeq} message(s) so far`,
2400
+ ...v.peers.map((peer) => ` ● ${peer.name}${peer.serial ? ` (${peer.serial})` : ''} [${peer.role}] last-seen ${new Date(peer.lastSeen).toISOString().slice(11, 19)}`),
2401
+ ...v.sessions.map((s) => ` ◈ ${s.id}: ${s.members.join(' ↔')} — ${JSON.stringify(s.policy)}${s.pending ? ` (${s.pending} undelivered)` : ''}`),
2402
+ ]
2403
+ return [{ type: 'text', text: lines.join('\n') }]
2404
+ },
2405
+ },
2406
+ async execute() {
2407
+ const status = mesh.status()
2408
+ return { peers: status.peers.map(clean), sessions: status.sessions, messageSeq: status.messageSeq }
2409
+ },
2410
+ })
2411
+ }
2412
+
2413
+ function meshSendTool(mesh) {
2414
+ return defineTool({
2415
+ name: 'mesh_send',
2416
+ description: 'Inject a JSON message into a mesh session AS an existing peer ("ghost" the other player) — the ' +
2417
+ 'fastest way to drive a co-op test without touching the app: the receiving game gets it from /mesh/poll ' +
2418
+ 'exactly as if its partner sent it, and the hub applies the session latency/drop/dup policy on delivery. ' +
2419
+ 'Every injection is logged (via: agent) so you can see how each side reacts.',
2420
+ parameters: {
2421
+ session: { type: 'string', description: 'Session id from mesh_status.' },
2422
+ from: { type: 'string', description: 'Peer name / id to speak as (must be a session member).' },
2423
+ body: { type: 'object', additionalProperties: true, description: 'JSON payload delivered verbatim to the other members.' },
2424
+ },
2425
+ output: {
2426
+ schema: {
2427
+ type: 'object',
2428
+ additionalProperties: false,
2429
+ properties: {
2430
+ seq: { type: 'integer', required: true },
2431
+ delivered: { type: 'integer', required: true },
2432
+ dropped: { type: 'integer', required: true },
2433
+ duplicated: { type: 'integer', required: true },
2434
+ readyAt: { type: 'integer' },
2435
+ },
2436
+ },
2437
+ render: (_args, value) => {
2438
+ const v = value ?? { seq: 0, delivered: 0, dropped: 0, duplicated: 0 }
2439
+ return [{ type: 'text', text: `mesh message #${v.seq}: delivered ${v.delivered}, dropped ${v.dropped}, duplicated ${v.duplicated}${v.readyAt ? ` (due ${new Date(v.readyAt).toISOString().slice(11, 23)})` : ''}` }]
2440
+ },
2441
+ },
2442
+ async execute(args) {
2443
+ const result = mesh.send({ session: args.session, from: args.from, body: args.body ?? null, via: 'agent' })
2444
+ return clean(result)
2445
+ },
2446
+ })
2447
+ }
2448
+
2449
+ function meshLogTool(mesh) {
2450
+ return defineTool({
2451
+ name: 'mesh_log',
2452
+ description: 'Read the mesh message log — every join/link/send/drop/dup/tune event with timestamps, sequence ' +
2453
+ 'numbers, byte sizes and the acting peer. This is the wire between the players: watch the game talk, spot ' +
2454
+ 'policy drops, and correlate traffic bursts with what device_pair_capture shows on both screens.',
2455
+ parameters: {
2456
+ session: { type: 'string', description: 'Only entries for this session id (default: all).' },
2457
+ limit: { type: 'integer', description: 'Most recent N entries (default 50, max 500).' },
2458
+ },
2459
+ output: {
2460
+ schema: {
2461
+ type: 'object',
2462
+ additionalProperties: false,
2463
+ properties: { entries: { type: 'array', items: { type: 'object', additionalProperties: true }, required: true } },
2464
+ },
2465
+ render: (_args, value) => {
2466
+ const entries = value?.entries ?? []
2467
+ const text = entries.length === 0
2468
+ ? 'mesh log is empty'
2469
+ : entries.map((e) => ` ${new Date(e.ts).toISOString().slice(11, 23)} ${e.kind} ${JSON.stringify(Object.fromEntries(Object.entries(e).filter(([k]) => !['seq', 'ts', 'kind'].includes(k))))}`).join('\n')
2470
+ return [{ type: 'text', text: `mesh log (${entries.length} entries):\n${text}` }]
2471
+ },
2472
+ },
2473
+ async execute(args) {
2474
+ return { entries: mesh.log({ session: args.session, limit: args.limit }) }
2475
+ },
2476
+ })
2477
+ }
2478
+
2479
+ function meshTuneTool(mesh) {
2480
+ return defineTool({
2481
+ name: 'mesh_tune',
2482
+ description: 'Set the network conditions a mesh session delivers under: latencyMs delay, jitterMs random extra ' +
2483
+ 'delay, dropPct random message loss, dupPct random duplicates, throttleKbps size-proportional delay (8000 bytes ' +
2484
+ 'at 8 Kbps = +1000 ms). Applied per send to every recipient, zero means OFF. This is the desync-testing lever: ' +
2485
+ 'make player B lag 400 ms with 100 ms jitter and watch the game handle it — no app code changes.',
2486
+ parameters: {
2487
+ session: { type: 'string', description: 'Session id from mesh_status.' },
2488
+ latencyMs: { type: 'integer', description: 'Fixed per-message delay, 0..5000.' },
2489
+ jitterMs: { type: 'integer', description: 'Random extra delay 0..jitterMs, 0..5000.' },
2490
+ dropPct: { type: 'number', description: 'Chance each recipient-copy is dropped, 0..100.' },
2491
+ dupPct: { type: 'number', description: 'Chance each delivered copy is duplicated, 0..100.' },
2492
+ throttleKbps: { type: 'integer', description: 'Bandwidth cap: adds bytes*8/kbps ms per message. 0 = unlimited.' },
2493
+ },
2494
+ output: {
2495
+ schema: {
2496
+ type: 'object',
2497
+ additionalProperties: false,
2498
+ properties: {
2499
+ session: { type: 'string', required: true },
2500
+ policy: MESH_POLICY_SCHEMA,
2501
+ },
2502
+ },
2503
+ render: (_args, value) => {
2504
+ const v = value ?? { session: '', policy: {} }
2505
+ return [{ type: 'text', text: `session ${v.session} policy → ${JSON.stringify(v.policy)}` }]
2506
+ },
2507
+ },
2508
+ async execute(args) {
2509
+ const policy = mesh.tune(args.session, {
2510
+ latencyMs: args.latencyMs, jitterMs: args.jitterMs, dropPct: args.dropPct, dupPct: args.dupPct, throttleKbps: args.throttleKbps,
2511
+ })
2512
+ return { session: args.session, policy }
2513
+ },
2514
+ })
2515
+ }
2516
+
2517
+ function meshResetTool(mesh) {
2518
+ return defineTool({
2519
+ name: 'mesh_reset',
2520
+ description: 'Clear mesh state: pass a session id to dissolve that one session, or nothing to wipe ALL peers and ' +
2521
+ 'sessions and the log. Devices re-join with /mesh/join afterwards (their callsigns stay pinned to serial), so a ' +
2522
+ 'reset between test runs is cheap.',
2523
+ parameters: {
2524
+ session: { type: 'string', description: 'Dissolve only this session (default: full wipe).' },
2525
+ },
2526
+ output: {
2527
+ schema: {
2528
+ type: 'object',
2529
+ additionalProperties: false,
2530
+ properties: { cleared: { type: 'string', required: true } },
2531
+ },
2532
+ render: (_args, value) => [{ type: 'text', text: `mesh cleared: ${value?.cleared ?? '?'}` }],
2533
+ },
2534
+ async execute(args) {
2535
+ if (typeof args.session === 'string' && args.session !== '') {
2536
+ mesh.unlink(args.session)
2537
+ return { cleared: `session ${args.session}` }
2538
+ }
2539
+ mesh.reset()
2540
+ return { cleared: 'everything (peers, sessions, log)' }
2541
+ },
2542
+ })
2543
+ }
2544
+
1830
2545
  function deviceBootTool() {
1831
2546
  return defineTool({
1832
2547
  name: 'device_boot',
@@ -2619,6 +3334,28 @@ function guidance() {
2619
3334
  ' check a running app\'s footprint.',
2620
3335
  '- device_backtrace: SIGQUIT an app process and read its newest /data/anr thread dump; falls back to the logcat crash buffer',
2621
3336
  ' when /data/anr is unreadable (engine field says which). Deterministic crash/ANR capture for debugging.',
3337
+ '- device_display: read or set a device\'s DPI (wm density) and pixel resolution (wm size) — action get/set/reset.',
3338
+ ' Use it to test layout scaling, or to make two devices share a viewport. Re-observe with device_screen after a change.',
3339
+ '- device_avd_create: create a second virtual device (clone_from copies an existing AVD\'s full hardware config so both',
3340
+ ' players behave identically). Boot it with device_boot; a running emulator holds 5554 so device #2 lands on 5556.',
3341
+ '- device_batch: fire 1..16 input actions concurrently across devices (each step carries its own serial) — the co-op',
3342
+ ' primitive for "both players press attack on the same frame". Per-step results; a failing step never blocks others.',
3343
+ '- device_pair_capture: capture BOTH devices\' screens in one call (parallel PNG + UI digests, both attached as real',
3344
+ ' image blocks) — the way to watch two players at the same instant. Add ocr:true only when UI trees are not enough.',
3345
+ '',
3346
+ 'Co-op mesh (v0.8.0) — two emulators cannot multicast-discover each other through their isolated NATs, so the plugin',
3347
+ 'hosts a LocalSend-style JSON pub/sub hub. Any app inside an emulator reaches it at http://10.0.2.2:<dsh-port>' +
3348
+ '/api/dsh-mobilecode/mesh/join — POST {serial?,name?} to join (the hub assigns a random callsign like "amber-fox",',
3349
+ 'stable per serial, and a token), POST /mesh/link {with:[names]} to form a session, then POST /mesh/send and GET',
3350
+ '/mesh/poll?id&token&after&wait to exchange JSON. The hub is also your observation window and network simulator:',
3351
+ '- mesh_status: who has joined, which sessions exist, live per-session policy and undelivered mail.',
3352
+ '- mesh_send: inject a message as any peer (the other game receives it from /mesh/poll as if its partner sent it).',
3353
+ '- mesh_log: every join/link/send/drop/dup/tune event with timestamps, sizes and latency — the wire between players.',
3354
+ '- mesh_tune: set a session\'s latencyMs / jitterMs / dropPct / dupPct / throttleKbps to desync-test the pair',
3355
+ ' (e.g. latency 400 + jitter 100 on player B) without touching app code.',
3356
+ '- mesh_reset: dissolve one session or wipe everything between test runs.',
3357
+ 'Typical co-op loop: device_avd_create + device_boot a clone → both apps join → mesh_link → device_batch inputs at',
3358
+ 'both, device_pair_capture to watch, mesh_log to see the traffic, mesh_tune to inject real-world network pain.',
2622
3359
  '',
2623
3360
  'Expo and React Native projects are handled automatically: expo prebuild runs when needed, Metro starts for you,',
2624
3361
  'and the app is installed and launched on the booted simulator/emulator. Failed builds report the error and a log tail.',
@@ -2647,10 +3384,12 @@ export function apply(ctx, config) {
2647
3384
  const engine = new DevicePreviewEngine()
2648
3385
  const streamHost = new AndroidStreamHost()
2649
3386
  const streamAccess = new StreamAccess.StreamAccessController()
3387
+ const mesh = new MeshHub()
2650
3388
  const vision = Vision.resolveVisionServices(ctx)
2651
3389
  const handle = {
2652
3390
  engine,
2653
3391
  stream: streamHost,
3392
+ mesh,
2654
3393
  status: () => ({
2655
3394
  directories: [...new Set([...engine.builds.keys()].map((key) => key.split('\0')[0]))],
2656
3395
  servers: [...engine.servers.keys()],
@@ -2661,7 +3400,7 @@ export function apply(ctx, config) {
2661
3400
  if (typeof ctx.provide === 'function') ctx.provide('mobilecode', handle)
2662
3401
  else ctx.mobilecode = handle
2663
3402
 
2664
- const routes = makeRoutes(engine, config, { host: streamHost, access: streamAccess })
3403
+ const routes = makeRoutes(engine, config, { host: streamHost, access: streamAccess, mesh })
2665
3404
  let disposeRoutes
2666
3405
  let disposeTools
2667
3406
  let disposeSection
@@ -2714,6 +3453,15 @@ export function apply(ctx, config) {
2714
3453
  deviceTapRowTool(),
2715
3454
  deviceBacktraceTool(),
2716
3455
  deviceMeminfoTool(),
3456
+ deviceDisplayTool(),
3457
+ deviceAvdCreateTool(),
3458
+ deviceBatchTool(),
3459
+ devicePairCaptureTool(vision),
3460
+ meshStatusTool(mesh),
3461
+ meshSendTool(mesh),
3462
+ meshLogTool(mesh),
3463
+ meshTuneTool(mesh),
3464
+ meshResetTool(mesh),
2717
3465
  ].map((tool) => ctx.tools.register(tool))
2718
3466
  return () => { for (const dispose of disposers) dispose() }
2719
3467
  }, 'dsh-mobilecode: tools')