dsh-mobilecode 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js CHANGED
@@ -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).
@@ -531,6 +564,10 @@ function makeRoutes(engine, config, stream) {
531
564
  const argv = DEVICE_ACTIONS[action]
532
565
  if (argv === undefined) { writeJson(res, 400, { code: 'unknown_action', error: `unknown device action "${action}"` }); return }
533
566
  if (!StreamAccess.SERIAL_PATTERN.test(serial)) { writeJson(res, 400, { code: 'bad_request', error: 'device must be an adb device serial' }); return }
567
+ if (serial !== stream.host.streamedSerial) {
568
+ const online = await stream.host.listDevices()
569
+ if (!online.some((device) => device.serial === serial)) { writeJson(res, 409, { code: 'device_not_found', error: `device ${serial} is not online` }); return }
570
+ }
534
571
  try {
535
572
  await DeviceBuild.adbRun(serial, ['shell', ...argv])
536
573
  writeJson(res, 200, { ok: true, action, device: serial })
@@ -586,6 +623,118 @@ function makeRoutes(engine, config, stream) {
586
623
  }
587
624
  },
588
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
+ },
589
738
  ]
590
739
  return routes
591
740
  }
@@ -1647,7 +1796,8 @@ function deviceBacktraceTool() {
1647
1796
  name: 'device_backtrace',
1648
1797
  description: 'Capture a native/ANR thread backtrace or crash log for an app on the device. Sends SIGQUIT (kill -3) to the process, ' +
1649
1798
  'waits for the ART runtime to write the thread dump, then reads the newest /data/anr entry. When /data/anr is unrunnable it falls ' +
1650
- 'back to the logcat crash buffer and says so (engine field: "anr-trace" | "logcat-crash"). Pass package_name or pid.',
1799
+ 'back to the logcat crash buffer and says so (engine field: "anr-trace" | "logcat-crash"). SIGQUIT refusal (system-uid or ' +
1800
+ 'non-debuggable process) degrades to the crash buffer with an explanatory note instead of failing. Pass package_name or pid.',
1651
1801
  parameters: {
1652
1802
  serial: { type: 'string', description: 'Android device serial. Omit to use the first attached device.' },
1653
1803
  package_name: { type: 'string', description: 'Package of the process to trace (e.g. com.simspoof.app).' },
@@ -1695,22 +1845,32 @@ function deviceBacktraceTool() {
1695
1845
  if (pid === undefined) {
1696
1846
  throw new Error(`device_backtrace could not resolve a running process for ${packageName ? `"${packageName}"` : 'the request'} on ${serial} — pass a package_name of a running app, or an explicit pid.`)
1697
1847
  }
1698
- // SIGQUIT: the ART runtime writes the thread dump to /data/anr/.
1699
- await DeviceBuild.adbRun(serial, ['shell', 'kill', '-3', String(pid)])
1700
- await new Promise((resolve) => setTimeout(resolve, 1200))
1701
- const listing = await DeviceBuild.adbRun(serial, ['shell', 'ls', '-t', '/data/anr']).catch(() => undefined)
1702
- const newest = (listing ?? '').split(/\r?\n/).map((line) => line.trim()).filter((line) => line !== '' && !line.includes(' '))[0]
1703
- if (newest !== undefined) {
1704
- const content = await DeviceBuild.adbRun(serial, ['shell', 'cat', DeviceBuild.shQuoteDevice(`/data/anr/${newest}`)], { timeoutMs: 30_000 }).catch(() => undefined)
1705
- if (content !== undefined && content.trim() !== '') {
1706
- return DeviceBuild.jsonSafe({
1707
- serial,
1708
- engine: 'anr-trace',
1709
- ...(packageName ? { package_name: packageName } : {}),
1710
- pid,
1711
- trace_path: `/data/anr/${newest}`,
1712
- lines: content.split(/\r?\n/).slice(-250),
1713
- })
1848
+ // SIGQUIT: the ART runtime writes the thread dump to /data/anr/. The adb
1849
+ // shell user cannot signal system-uid / non-debuggable processes on
1850
+ // enforcing builds (EPERM) degrade honestly instead of throwing.
1851
+ let sigquit = true
1852
+ try {
1853
+ await DeviceBuild.adbRun(serial, ['shell', 'kill', '-3', String(pid)])
1854
+ } catch {
1855
+ sigquit = false
1856
+ }
1857
+ let newest
1858
+ if (sigquit) {
1859
+ await new Promise((resolve) => setTimeout(resolve, 1200))
1860
+ const listing = await DeviceBuild.adbRun(serial, ['shell', 'ls', '-t', '/data/anr']).catch(() => undefined)
1861
+ newest = (listing ?? '').split(/\r?\n/).map((line) => line.trim()).filter((line) => line !== '' && !line.includes(' '))[0]
1862
+ if (newest !== undefined) {
1863
+ const content = await DeviceBuild.adbRun(serial, ['shell', 'cat', DeviceBuild.shQuoteDevice(`/data/anr/${newest}`)], { timeoutMs: 30_000 }).catch(() => undefined)
1864
+ if (content !== undefined && content.trim() !== '') {
1865
+ return DeviceBuild.jsonSafe({
1866
+ serial,
1867
+ engine: 'anr-trace',
1868
+ ...(packageName ? { package_name: packageName } : {}),
1869
+ pid,
1870
+ trace_path: `/data/anr/${newest}`,
1871
+ lines: content.split(/\r?\n/).slice(-250),
1872
+ })
1873
+ }
1714
1874
  }
1715
1875
  }
1716
1876
  // Fallback: the logcat crash buffer (FATAL/AndroidRuntime lines), filtered by package when known.
@@ -1724,7 +1884,9 @@ function deviceBacktraceTool() {
1724
1884
  engine: 'logcat-crash',
1725
1885
  ...(packageName ? { package_name: packageName } : {}),
1726
1886
  pid,
1727
- note: `SIGQUIT sent to pid ${pid}; /data/anr was ${newest === undefined ? 'empty/unreadable' : `read but produced no content for ${newest}`}; the logcat crash buffer has no matching FATAL lines now. The app may not have crashed — check device_log for the main buffer.`,
1887
+ note: sigquit
1888
+ ? `SIGQUIT sent to pid ${pid}; /data/anr was ${newest === undefined ? 'empty/unreadable' : `read but produced no content for ${newest}`}; the logcat crash buffer has no matching FATAL lines now. The app may not have crashed — check device_log for the main buffer.`
1889
+ : `SIGQUIT refused for pid ${pid} — the adb shell user cannot signal this process (system uid or non-debuggable app on an enforcing build), so no fresh thread dump exists; the logcat crash buffer has no matching FATAL lines either. Target a debuggable app for a live dump, or use an adb-rooted device.`,
1728
1890
  lines: [],
1729
1891
  })
1730
1892
  }
@@ -1810,6 +1972,572 @@ function deviceMeminfoTool() {
1810
1972
  })
1811
1973
  }
1812
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
+ const created = await DeviceBuild.createAvd({ name, imageId, deviceProfile: imageId && !cloneConfig ? (args.device ?? 'pixel_7') : undefined, cloneConfigText: cloneConfig })
2124
+ return { name, imageId, configPath: created.configPath, ...(args.clone_from ? { clonedFrom: args.clone_from } : {}) }
2125
+ },
2126
+ })
2127
+ }
2128
+
2129
+ /**
2130
+ * Pure adb-argv builder for one device_batch step (exported for offline tests).
2131
+ * Throws with the offending step named when the parameters do not fit the action.
2132
+ */
2133
+ export function buildInputArgv(step) {
2134
+ const action = step?.action ?? 'tap'
2135
+ const int = (value) => (typeof value === 'number' && Number.isFinite(value) ? Math.round(value) : undefined)
2136
+ switch (action) {
2137
+ case 'tap': {
2138
+ const x = int(step.x)
2139
+ const y = int(step.y)
2140
+ if (x === undefined || y === undefined) throw new Error('step action=tap needs numeric x and y.')
2141
+ return { argv: ['shell', 'input', 'tap', String(x), String(y)], sent: `tap ${x},${y}` }
2142
+ }
2143
+ case 'swipe': {
2144
+ const x = int(step.x)
2145
+ const y = int(step.y)
2146
+ const x2 = int(step.x2)
2147
+ const y2 = int(step.y2)
2148
+ if ([x, y, x2, y2].some((value) => value === undefined)) throw new Error('step action=swipe needs numeric x, y, x2, y2.')
2149
+ const duration = int(step.duration) ?? 200
2150
+ return { argv: ['shell', 'input', 'swipe', String(x), String(y), String(x2), String(y2), String(duration)], sent: `swipe ${x},${y}→${x2},${y2}` }
2151
+ }
2152
+ case 'text': {
2153
+ const text = step.text
2154
+ if (typeof text !== 'string' || text === '') throw new Error('step action=text needs a non-empty text string.')
2155
+ if (!DeviceBuild.isAsciiInput(text)) throw new Error(`batch action=text is ASCII-only (non-ASCII needs the ADBKeyboard path — use device_input): "${text}".`)
2156
+ return { argv: ['shell', 'input', 'text', DeviceBuild.escapeInputText(text)], sent: `text "${text}"` }
2157
+ }
2158
+ case 'key': {
2159
+ const raw = String(step.key ?? '')
2160
+ const code = /^\d+$/.test(raw) ? Number(raw) : KEYCODES[raw.toLowerCase()]
2161
+ if (!code) throw new Error(`unknown key "${raw}" in batch step.`)
2162
+ return { argv: ['shell', 'input', 'keyevent', String(code)], sent: `key ${raw}` }
2163
+ }
2164
+ default:
2165
+ throw new Error(`unknown step action "${action}" — use tap, swipe, text or key.`)
2166
+ }
2167
+ }
2168
+
2169
+ function deviceBatchTool() {
2170
+ return defineTool({
2171
+ name: 'device_batch',
2172
+ description: 'Fire several input actions at once across devices — the co-op primitive for "both players press ' +
2173
+ 'attack on the same frame". All steps dispatch concurrently (separate adb connections, spawned in parallel, so ' +
2174
+ 'cross-device skew is a few milliseconds), each step carries the same fields device_input uses plus its own ' +
2175
+ 'serial (omit serial to target the first device). A failing step never blocks the others; the per-step results ' +
2176
+ 'say what landed. Follow with device_pair_capture to watch what both screens did.',
2177
+ parameters: {
2178
+ steps: {
2179
+ type: 'array',
2180
+ items: {
2181
+ type: 'object',
2182
+ additionalProperties: true,
2183
+ properties: {
2184
+ serial: { type: 'string' },
2185
+ action: { type: 'string', enum: ['tap', 'swipe', 'text', 'key'] },
2186
+ x: { type: 'integer' }, y: { type: 'integer' }, x2: { type: 'integer' }, y2: { type: 'integer' },
2187
+ duration: { type: 'integer' }, text: { type: 'string' }, key: { type: 'string' },
2188
+ },
2189
+ },
2190
+ description: '1..16 input steps, e.g. [{serial:"emulator-5554",action:"tap",x:540,y:1200},{serial:"emulator-5556",action:"key",key:"space"}].',
2191
+ },
2192
+ settle_ms: { type: 'integer', description: 'After the last dispatch, wait this long (0..10000, default 0) so the game can react before you observe.' },
2193
+ },
2194
+ output: {
2195
+ schema: {
2196
+ type: 'object',
2197
+ additionalProperties: false,
2198
+ properties: {
2199
+ ok: { type: 'boolean', required: true },
2200
+ results: {
2201
+ type: 'array',
2202
+ required: true,
2203
+ items: {
2204
+ type: 'object',
2205
+ additionalProperties: false,
2206
+ properties: {
2207
+ index: { type: 'integer', required: true },
2208
+ serial: { type: 'string', required: true },
2209
+ ok: { type: 'boolean', required: true },
2210
+ sent: { type: 'string' },
2211
+ error: { type: 'string' },
2212
+ },
2213
+ },
2214
+ },
2215
+ },
2216
+ },
2217
+ render: (_args, value) => {
2218
+ const v = value ?? { ok: false, results: [] }
2219
+ const lines = v.results.map((r) => ` #${r.index} ${r.serial}: ${r.ok ? r.sent : `FAILED — ${r.error}`}`)
2220
+ return [{ type: 'text', text: `device_batch ${v.ok ? 'all landed' : 'had failures'}:\n${lines.join('\n')}` }]
2221
+ },
2222
+ },
2223
+ async execute(args) {
2224
+ const steps = Array.isArray(args.steps) ? args.steps : []
2225
+ if (steps.length === 0 || steps.length > 16) throw new Error('steps must hold 1..16 input actions.')
2226
+ const planned = steps.map((step, index) => ({ index, step, ...buildInputArgv(step) }))
2227
+ let fallback
2228
+ for (const item of planned) {
2229
+ if (typeof item.step.serial === 'string' && item.step.serial !== '') item.serial = item.step.serial
2230
+ else { fallback ??= await requireAndroidDevice(undefined); item.serial = fallback }
2231
+ }
2232
+ const results = await Promise.all(planned.map(async (item) => {
2233
+ try {
2234
+ await DeviceBuild.adbRun(item.serial, item.argv)
2235
+ return { index: item.index, serial: item.serial, ok: true, sent: item.sent }
2236
+ } catch (error) {
2237
+ return { index: item.index, serial: item.serial, ok: false, error: error instanceof Error ? error.message : String(error) }
2238
+ }
2239
+ }))
2240
+ const settle = Math.min(Math.max(args.settle_ms ?? 0, 0), 10_000)
2241
+ if (settle > 0) await new Promise((resolve) => setTimeout(resolve, settle))
2242
+ return { ok: results.every((r) => r.ok), results }
2243
+ },
2244
+ })
2245
+ }
2246
+
2247
+ /** One device's screen state for device_pair_capture (mirrors device_screen's parts). */
2248
+ async function captureForPair(serial, directory, wantOcr) {
2249
+ const [png, ui, foreground, size] = await Promise.all([
2250
+ DeviceBuild.screenCapture(serial, directory).catch(() => undefined),
2251
+ DeviceBuild.uiDump(serial).catch(() => []),
2252
+ DeviceBuild.foregroundActivity(serial).catch(() => undefined),
2253
+ captureScreenSize(serial).catch(() => undefined),
2254
+ ])
2255
+ const out = { serial, ui, ...(foreground ? { foreground } : {}), ...(png ? { screenshot: png } : {}) }
2256
+ if (png && size) { out.width = size.width; out.height = size.height }
2257
+ if (wantOcr) {
2258
+ if (!png) out.ocrError = 'screenshot failed'
2259
+ else {
2260
+ const ocr = await DeviceBuild.ocrImage(png).catch(() => [])
2261
+ if (ocr.length > 0) out.ocr = ocr
2262
+ else out.ocrError = 'PaddleOCR returned no text'
2263
+ }
2264
+ }
2265
+ return out
2266
+ }
2267
+
2268
+ function devicePairCaptureTool(vision) {
2269
+ return defineTool({
2270
+ name: 'device_pair_capture',
2271
+ description: 'Watch two devices at the same instant: one call captures BOTH screens (PNG + uiautomator digest, ' +
2272
+ 'optional OCR) in parallel and attaches both screenshots as real image blocks, so a vision model sees both ' +
2273
+ 'players in a single glance. This is the co-op observation primitive — call it right after device_batch (or any ' +
2274
+ 'mesh_send) to watch the reaction on both sides.',
2275
+ parameters: {
2276
+ serials: { type: 'array', items: { type: 'string' }, description: 'Exactly two device serials. Defaults to the first two attached devices.' },
2277
+ ocr: { type: 'boolean', description: 'Run PaddleOCR on both screenshots (default false — slower). UI trees usually suffice.' },
2278
+ directory: { type: 'string', description: 'Where to store the PNGs (default: temp).' },
2279
+ },
2280
+ output: {
2281
+ schema: {
2282
+ type: 'object',
2283
+ additionalProperties: false,
2284
+ properties: {
2285
+ devices: {
2286
+ type: 'array',
2287
+ required: true,
2288
+ items: {
2289
+ type: 'object',
2290
+ additionalProperties: false,
2291
+ properties: {
2292
+ serial: { type: 'string', required: true },
2293
+ screenshot: { type: 'string' },
2294
+ width: { type: 'integer' },
2295
+ height: { type: 'integer' },
2296
+ foreground: { type: 'string' },
2297
+ ui: { type: 'array', items: { type: 'object', additionalProperties: false, properties: { text: { type: 'string', required: true }, resourceId: { type: 'string' }, bounds: { type: 'array', items: { type: 'integer' } } } } },
2298
+ ocr: { type: 'array', items: { type: 'object', additionalProperties: false, properties: { text: { type: 'string', required: true }, confidence: { type: 'number' }, box: { type: 'array', items: { type: 'integer' } } } } },
2299
+ ocrError: { type: 'string' },
2300
+ },
2301
+ },
2302
+ },
2303
+ images: { type: 'array', items: Vision.IMAGE_REF_SCHEMA },
2304
+ },
2305
+ },
2306
+ render: (_args, value) => {
2307
+ const v = value ?? { devices: [] }
2308
+ const blocks = [{ type: 'text', text: 'Pair capture (both devices, same instant):' }]
2309
+ for (const device of v.devices) {
2310
+ blocks.push({
2311
+ type: 'text',
2312
+ text: [
2313
+ `▶ ${device.serial}${device.foreground ? ` — foreground: ${device.foreground}` : ''}${device.screenshot ? ` [${device.screenshot}${device.width ? ` ${device.width}x${device.height}` : ''}]` : ''}`,
2314
+ ...(device.ui ?? []).slice(0, 25).map((item) => ` - ${item.text}${item.resourceId ? ` [${item.resourceId}]` : ''}${item.bounds ? ` @${item.bounds.join(',')}` : ''}`),
2315
+ ...((device.ui?.length ?? 0) > 25 ? [` … and ${device.ui.length - 25} more`] : []),
2316
+ ...(device.ocr ?? []).slice(0, 15).map((item) => ` “${item.text}” (${Math.round(item.confidence * 100)}%)`),
2317
+ ...(device.ocrError ? [` OCR unavailable: ${device.ocrError}`] : []),
2318
+ ].join('\n'),
2319
+ })
2320
+ }
2321
+ return Vision.appendImageBlock(blocks, v)
2322
+ },
2323
+ },
2324
+ async execute(args, exec) {
2325
+ let serials = (Array.isArray(args.serials) ? args.serials : []).filter((s) => typeof s === 'string' && s !== '').slice(0, 2)
2326
+ if (serials.length < 2) {
2327
+ const rows = await DeviceBuild.devices()
2328
+ const online = [...new Set([...serials, ...rows.filter((r) => r.state === 'device').map((r) => r.serial)])]
2329
+ serials = online.slice(0, 2)
2330
+ }
2331
+ 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.`)
2332
+ const wantOcr = args.ocr === true
2333
+ const devices = await Promise.all(serials.map((serial) => captureForPair(serial, args.directory, wantOcr)))
2334
+ const images = (await Promise.all(devices.map((device) => Vision.maybeAttachScreenshot(vision, device.screenshot, exec)))).filter((image) => image !== undefined)
2335
+ return { devices, ...(images.length > 0 ? { images } : {}) }
2336
+ },
2337
+ })
2338
+ }
2339
+
2340
+ const MESH_POLICY_SCHEMA = {
2341
+ type: 'object',
2342
+ additionalProperties: false,
2343
+ properties: {
2344
+ latencyMs: { type: 'integer', required: true },
2345
+ jitterMs: { type: 'integer', required: true },
2346
+ dropPct: { type: 'number', required: true },
2347
+ dupPct: { type: 'number', required: true },
2348
+ throttleKbps: { type: 'integer', required: true },
2349
+ },
2350
+ }
2351
+ const MESH_PEER_SCHEMA = {
2352
+ type: 'object',
2353
+ additionalProperties: false,
2354
+ properties: {
2355
+ id: { type: 'string', required: true },
2356
+ name: { type: 'string', required: true },
2357
+ role: { type: 'string', required: true },
2358
+ joinedAt: { type: 'integer', required: true },
2359
+ lastSeen: { type: 'integer', required: true },
2360
+ serial: { type: 'string' },
2361
+ },
2362
+ }
2363
+ const MESH_SESSION_SCHEMA = {
2364
+ type: 'object',
2365
+ additionalProperties: false,
2366
+ properties: {
2367
+ id: { type: 'string', required: true },
2368
+ members: { type: 'array', items: { type: 'string' }, required: true },
2369
+ policy: MESH_POLICY_SCHEMA,
2370
+ pending: { type: 'integer', required: true },
2371
+ },
2372
+ }
2373
+
2374
+ function meshStatusTool(mesh) {
2375
+ return defineTool({
2376
+ name: 'mesh_status',
2377
+ description: 'Inspect the host mesh hub: every peer that joined from a device (a random callsign pinned to its ' +
2378
+ 'serial, LocalSend-style), every session with its members, live network policy and undelivered mail. Devices ' +
2379
+ 'reach this hub at http://10.0.2.2:<dsh-port>/api/dsh-mobilecode/mesh/* — games POST join {serial?,name?}, ' +
2380
+ 'then link/send/poll with the returned id+token.',
2381
+ parameters: {},
2382
+ output: {
2383
+ schema: {
2384
+ type: 'object',
2385
+ additionalProperties: false,
2386
+ properties: {
2387
+ peers: { type: 'array', items: MESH_PEER_SCHEMA, required: true },
2388
+ sessions: { type: 'array', items: MESH_SESSION_SCHEMA, required: true },
2389
+ messageSeq: { type: 'integer', required: true },
2390
+ },
2391
+ },
2392
+ render: (_args, value) => {
2393
+ const v = value ?? { peers: [], sessions: [], messageSeq: 0 }
2394
+ const lines = [
2395
+ `mesh: ${v.peers.length} peer(s), ${v.sessions.length} session(s), ${v.messageSeq} message(s) so far`,
2396
+ ...v.peers.map((peer) => ` ● ${peer.name}${peer.serial ? ` (${peer.serial})` : ''} [${peer.role}] last-seen ${new Date(peer.lastSeen).toISOString().slice(11, 19)}`),
2397
+ ...v.sessions.map((s) => ` ◈ ${s.id}: ${s.members.join(' ↔')} — ${JSON.stringify(s.policy)}${s.pending ? ` (${s.pending} undelivered)` : ''}`),
2398
+ ]
2399
+ return [{ type: 'text', text: lines.join('\n') }]
2400
+ },
2401
+ },
2402
+ async execute() {
2403
+ const status = mesh.status()
2404
+ return { peers: status.peers.map(clean), sessions: status.sessions, messageSeq: status.messageSeq }
2405
+ },
2406
+ })
2407
+ }
2408
+
2409
+ function meshSendTool(mesh) {
2410
+ return defineTool({
2411
+ name: 'mesh_send',
2412
+ description: 'Inject a JSON message into a mesh session AS an existing peer ("ghost" the other player) — the ' +
2413
+ 'fastest way to drive a co-op test without touching the app: the receiving game gets it from /mesh/poll ' +
2414
+ 'exactly as if its partner sent it, and the hub applies the session latency/drop/dup policy on delivery. ' +
2415
+ 'Every injection is logged (via: agent) so you can see how each side reacts.',
2416
+ parameters: {
2417
+ session: { type: 'string', description: 'Session id from mesh_status.' },
2418
+ from: { type: 'string', description: 'Peer name / id to speak as (must be a session member).' },
2419
+ body: { type: 'object', additionalProperties: true, description: 'JSON payload delivered verbatim to the other members.' },
2420
+ },
2421
+ output: {
2422
+ schema: {
2423
+ type: 'object',
2424
+ additionalProperties: false,
2425
+ properties: {
2426
+ seq: { type: 'integer', required: true },
2427
+ delivered: { type: 'integer', required: true },
2428
+ dropped: { type: 'integer', required: true },
2429
+ duplicated: { type: 'integer', required: true },
2430
+ readyAt: { type: 'integer' },
2431
+ },
2432
+ },
2433
+ render: (_args, value) => {
2434
+ const v = value ?? { seq: 0, delivered: 0, dropped: 0, duplicated: 0 }
2435
+ 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)})` : ''}` }]
2436
+ },
2437
+ },
2438
+ async execute(args) {
2439
+ const result = mesh.send({ session: args.session, from: args.from, body: args.body ?? null, via: 'agent' })
2440
+ return clean(result)
2441
+ },
2442
+ })
2443
+ }
2444
+
2445
+ function meshLogTool(mesh) {
2446
+ return defineTool({
2447
+ name: 'mesh_log',
2448
+ description: 'Read the mesh message log — every join/link/send/drop/dup/tune event with timestamps, sequence ' +
2449
+ 'numbers, byte sizes and the acting peer. This is the wire between the players: watch the game talk, spot ' +
2450
+ 'policy drops, and correlate traffic bursts with what device_pair_capture shows on both screens.',
2451
+ parameters: {
2452
+ session: { type: 'string', description: 'Only entries for this session id (default: all).' },
2453
+ limit: { type: 'integer', description: 'Most recent N entries (default 50, max 500).' },
2454
+ },
2455
+ output: {
2456
+ schema: {
2457
+ type: 'object',
2458
+ additionalProperties: false,
2459
+ properties: { entries: { type: 'array', items: { type: 'object', additionalProperties: true }, required: true } },
2460
+ },
2461
+ render: (_args, value) => {
2462
+ const entries = value?.entries ?? []
2463
+ const text = entries.length === 0
2464
+ ? 'mesh log is empty'
2465
+ : 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')
2466
+ return [{ type: 'text', text: `mesh log (${entries.length} entries):\n${text}` }]
2467
+ },
2468
+ },
2469
+ async execute(args) {
2470
+ return { entries: mesh.log({ session: args.session, limit: args.limit }) }
2471
+ },
2472
+ })
2473
+ }
2474
+
2475
+ function meshTuneTool(mesh) {
2476
+ return defineTool({
2477
+ name: 'mesh_tune',
2478
+ description: 'Set the network conditions a mesh session delivers under: latencyMs delay, jitterMs random extra ' +
2479
+ 'delay, dropPct random message loss, dupPct random duplicates, throttleKbps size-proportional delay (8000 bytes ' +
2480
+ 'at 8 Kbps = +1000 ms). Applied per send to every recipient, zero means OFF. This is the desync-testing lever: ' +
2481
+ 'make player B lag 400 ms with 100 ms jitter and watch the game handle it — no app code changes.',
2482
+ parameters: {
2483
+ session: { type: 'string', description: 'Session id from mesh_status.' },
2484
+ latencyMs: { type: 'integer', description: 'Fixed per-message delay, 0..5000.' },
2485
+ jitterMs: { type: 'integer', description: 'Random extra delay 0..jitterMs, 0..5000.' },
2486
+ dropPct: { type: 'number', description: 'Chance each recipient-copy is dropped, 0..100.' },
2487
+ dupPct: { type: 'number', description: 'Chance each delivered copy is duplicated, 0..100.' },
2488
+ throttleKbps: { type: 'integer', description: 'Bandwidth cap: adds bytes*8/kbps ms per message. 0 = unlimited.' },
2489
+ },
2490
+ output: {
2491
+ schema: {
2492
+ type: 'object',
2493
+ additionalProperties: false,
2494
+ properties: {
2495
+ session: { type: 'string', required: true },
2496
+ policy: MESH_POLICY_SCHEMA,
2497
+ },
2498
+ },
2499
+ render: (_args, value) => {
2500
+ const v = value ?? { session: '', policy: {} }
2501
+ return [{ type: 'text', text: `session ${v.session} policy → ${JSON.stringify(v.policy)}` }]
2502
+ },
2503
+ },
2504
+ async execute(args) {
2505
+ const policy = mesh.tune(args.session, {
2506
+ latencyMs: args.latencyMs, jitterMs: args.jitterMs, dropPct: args.dropPct, dupPct: args.dupPct, throttleKbps: args.throttleKbps,
2507
+ })
2508
+ return { session: args.session, policy }
2509
+ },
2510
+ })
2511
+ }
2512
+
2513
+ function meshResetTool(mesh) {
2514
+ return defineTool({
2515
+ name: 'mesh_reset',
2516
+ description: 'Clear mesh state: pass a session id to dissolve that one session, or nothing to wipe ALL peers and ' +
2517
+ 'sessions and the log. Devices re-join with /mesh/join afterwards (their callsigns stay pinned to serial), so a ' +
2518
+ 'reset between test runs is cheap.',
2519
+ parameters: {
2520
+ session: { type: 'string', description: 'Dissolve only this session (default: full wipe).' },
2521
+ },
2522
+ output: {
2523
+ schema: {
2524
+ type: 'object',
2525
+ additionalProperties: false,
2526
+ properties: { cleared: { type: 'string', required: true } },
2527
+ },
2528
+ render: (_args, value) => [{ type: 'text', text: `mesh cleared: ${value?.cleared ?? '?'}` }],
2529
+ },
2530
+ async execute(args) {
2531
+ if (typeof args.session === 'string' && args.session !== '') {
2532
+ mesh.unlink(args.session)
2533
+ return { cleared: `session ${args.session}` }
2534
+ }
2535
+ mesh.reset()
2536
+ return { cleared: 'everything (peers, sessions, log)' }
2537
+ },
2538
+ })
2539
+ }
2540
+
1813
2541
  function deviceBootTool() {
1814
2542
  return defineTool({
1815
2543
  name: 'device_boot',
@@ -2602,6 +3330,28 @@ function guidance() {
2602
3330
  ' check a running app\'s footprint.',
2603
3331
  '- device_backtrace: SIGQUIT an app process and read its newest /data/anr thread dump; falls back to the logcat crash buffer',
2604
3332
  ' when /data/anr is unreadable (engine field says which). Deterministic crash/ANR capture for debugging.',
3333
+ '- device_display: read or set a device\'s DPI (wm density) and pixel resolution (wm size) — action get/set/reset.',
3334
+ ' Use it to test layout scaling, or to make two devices share a viewport. Re-observe with device_screen after a change.',
3335
+ '- device_avd_create: create a second virtual device (clone_from copies an existing AVD\'s full hardware config so both',
3336
+ ' players behave identically). Boot it with device_boot; a running emulator holds 5554 so device #2 lands on 5556.',
3337
+ '- device_batch: fire 1..16 input actions concurrently across devices (each step carries its own serial) — the co-op',
3338
+ ' primitive for "both players press attack on the same frame". Per-step results; a failing step never blocks others.',
3339
+ '- device_pair_capture: capture BOTH devices\' screens in one call (parallel PNG + UI digests, both attached as real',
3340
+ ' image blocks) — the way to watch two players at the same instant. Add ocr:true only when UI trees are not enough.',
3341
+ '',
3342
+ 'Co-op mesh (v0.8.0) — two emulators cannot multicast-discover each other through their isolated NATs, so the plugin',
3343
+ 'hosts a LocalSend-style JSON pub/sub hub. Any app inside an emulator reaches it at http://10.0.2.2:<dsh-port>' +
3344
+ '/api/dsh-mobilecode/mesh/join — POST {serial?,name?} to join (the hub assigns a random callsign like "amber-fox",',
3345
+ 'stable per serial, and a token), POST /mesh/link {with:[names]} to form a session, then POST /mesh/send and GET',
3346
+ '/mesh/poll?id&token&after&wait to exchange JSON. The hub is also your observation window and network simulator:',
3347
+ '- mesh_status: who has joined, which sessions exist, live per-session policy and undelivered mail.',
3348
+ '- mesh_send: inject a message as any peer (the other game receives it from /mesh/poll as if its partner sent it).',
3349
+ '- mesh_log: every join/link/send/drop/dup/tune event with timestamps, sizes and latency — the wire between players.',
3350
+ '- mesh_tune: set a session\'s latencyMs / jitterMs / dropPct / dupPct / throttleKbps to desync-test the pair',
3351
+ ' (e.g. latency 400 + jitter 100 on player B) without touching app code.',
3352
+ '- mesh_reset: dissolve one session or wipe everything between test runs.',
3353
+ 'Typical co-op loop: device_avd_create + device_boot a clone → both apps join → mesh_link → device_batch inputs at',
3354
+ 'both, device_pair_capture to watch, mesh_log to see the traffic, mesh_tune to inject real-world network pain.',
2605
3355
  '',
2606
3356
  'Expo and React Native projects are handled automatically: expo prebuild runs when needed, Metro starts for you,',
2607
3357
  'and the app is installed and launched on the booted simulator/emulator. Failed builds report the error and a log tail.',
@@ -2630,10 +3380,12 @@ export function apply(ctx, config) {
2630
3380
  const engine = new DevicePreviewEngine()
2631
3381
  const streamHost = new AndroidStreamHost()
2632
3382
  const streamAccess = new StreamAccess.StreamAccessController()
3383
+ const mesh = new MeshHub()
2633
3384
  const vision = Vision.resolveVisionServices(ctx)
2634
3385
  const handle = {
2635
3386
  engine,
2636
3387
  stream: streamHost,
3388
+ mesh,
2637
3389
  status: () => ({
2638
3390
  directories: [...new Set([...engine.builds.keys()].map((key) => key.split('\0')[0]))],
2639
3391
  servers: [...engine.servers.keys()],
@@ -2644,7 +3396,7 @@ export function apply(ctx, config) {
2644
3396
  if (typeof ctx.provide === 'function') ctx.provide('mobilecode', handle)
2645
3397
  else ctx.mobilecode = handle
2646
3398
 
2647
- const routes = makeRoutes(engine, config, { host: streamHost, access: streamAccess })
3399
+ const routes = makeRoutes(engine, config, { host: streamHost, access: streamAccess, mesh })
2648
3400
  let disposeRoutes
2649
3401
  let disposeTools
2650
3402
  let disposeSection
@@ -2697,6 +3449,15 @@ export function apply(ctx, config) {
2697
3449
  deviceTapRowTool(),
2698
3450
  deviceBacktraceTool(),
2699
3451
  deviceMeminfoTool(),
3452
+ deviceDisplayTool(),
3453
+ deviceAvdCreateTool(),
3454
+ deviceBatchTool(),
3455
+ devicePairCaptureTool(vision),
3456
+ meshStatusTool(mesh),
3457
+ meshSendTool(mesh),
3458
+ meshLogTool(mesh),
3459
+ meshTuneTool(mesh),
3460
+ meshResetTool(mesh),
2700
3461
  ].map((tool) => ctx.tools.register(tool))
2701
3462
  return () => { for (const dispose of disposers) dispose() }
2702
3463
  }, 'dsh-mobilecode: tools')