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.
@@ -0,0 +1,343 @@
1
+ // mesh-hub.js — host-mediated LocalSend-style mesh for co-op device testing.
2
+ //
3
+ // Two Android emulators cannot multicast-discover each other (each sits behind
4
+ // its own slirp NAT), but both can reach the host at 10.0.2.2. So the hub runs
5
+ // on the host: peers join with a persistent random callsign, link into a
6
+ // session, and exchange JSON messages through it. Every message is logged
7
+ // (with drop/dup marks) and every delivery is governed by a per-session policy
8
+ // (latency, jitter, drop, duplicate, bandwidth throttle) — the same hub is
9
+ // the game's network backbone AND the agent's observation window.
10
+ //
11
+ // Pure module: clock and RNG are injectable so the whole queue/policy engine
12
+ // is unit-testable offline (test/mesh-hub.mjs).
13
+
14
+ import { createHmac, randomBytes } from 'node:crypto'
15
+
16
+ const ADJECTIVES = [
17
+ 'amber', 'brisk', 'cobalt', 'dusky', 'eager', 'fable', 'gentle', 'hollow',
18
+ 'ivory', 'jolly', 'keen', 'lunar', 'mellow', 'noble', 'onyx', 'prism',
19
+ 'quiet', 'rapid', 'solar', 'tidal', 'umber', 'vivid', 'warm', 'zesty',
20
+ 'bold', 'crisp', 'dandy', 'eery', 'fuzzy', 'gilded', 'happy', 'mystic',
21
+ ]
22
+ const ANIMALS = [
23
+ 'fox', 'owl', 'hare', 'bear', 'lynx', 'wren', 'moth', 'toad',
24
+ 'crow', 'deer', 'goat', 'kiwi', 'newt', 'puma', 'quail', 'seal',
25
+ 'tern', 'vole', 'wasp', 'yak', 'zebu', 'crane', 'finch', 'gecko',
26
+ 'heron', 'ibis', 'jackal', 'koala', 'lemur', 'narwhal', 'otter', 'panda',
27
+ ]
28
+
29
+ function fnv1a(text) {
30
+ let h = 0x811c9dc5
31
+ for (let i = 0; i < text.length; i++) {
32
+ h ^= text.charCodeAt(i)
33
+ h = Math.imul(h, 0x01000193)
34
+ }
35
+ return h >>> 0
36
+ }
37
+
38
+ function hex16(text) {
39
+ return fnv1a(text).toString(16).padStart(8, '0') + fnv1a(text + '#2').toString(16).padStart(8, '0')
40
+ }
41
+
42
+ /** Deterministic LocalSend-style callsign for a stable seed (e.g. a serial). */
43
+ export function callsign(seed) {
44
+ const h = fnv1a('mesh-name:' + seed)
45
+ return ADJECTIVES[h % ADJECTIVES.length] + '-' + ANIMALS[(h >>> 5) % ANIMALS.length]
46
+ }
47
+
48
+ const clamp = (value, min, max) => Math.min(Math.max(value, min), max)
49
+ const num = (value, fallback) => (typeof value === 'number' && Number.isFinite(value) ? value : fallback)
50
+
51
+ /** Per-session network-condition policy. All knobs default to OFF. */
52
+ function makePolicy(input) {
53
+ return {
54
+ latencyMs: clamp(Math.round(num(input?.latencyMs, 0)), 0, 5000),
55
+ jitterMs: clamp(Math.round(num(input?.jitterMs, 0)), 0, 5000),
56
+ dropPct: clamp(num(input?.dropPct, 0), 0, 100),
57
+ dupPct: clamp(num(input?.dupPct, 0), 0, 100),
58
+ throttleKbps: clamp(Math.round(num(input?.throttleKbps, 0)), 0, 1_000_000),
59
+ }
60
+ }
61
+
62
+ export class MeshHub {
63
+ /**
64
+ * @param {object} opts
65
+ * @param {Buffer|string} [opts.secret] token signing key (per-process by default)
66
+ * @param {() => number} [opts.now] injectable clock (ms)
67
+ * @param {() => number} [opts.random] injectable RNG 0..1
68
+ * @param {number} [opts.maxLog] log ring size
69
+ */
70
+ constructor(opts = {}) {
71
+ this.secret = opts.secret ?? randomBytes(32)
72
+ this.now = opts.now ?? (() => Date.now())
73
+ this.random = opts.random ?? Math.random
74
+ this.maxLog = opts.maxLog ?? 500
75
+ this.peers = new Map() // id -> {id,name,serial,role,joinedAt,lastSeen}
76
+ this.sessions = new Map() // id -> {id,members:[peerId],policy,createdAt}
77
+ this.inboxes = new Map() // peerId -> envelope[] (kept seq-sorted by push order)
78
+ this.entries = [] // log ring
79
+ this.seq = 0
80
+ this.messageSeq = 0
81
+ this.waiters = [] // long-poll resolvers
82
+ }
83
+
84
+ // ---- auth ---------------------------------------------------------------
85
+ // Tokens are HMAC(process-secret, peerId): stable while the host lives,
86
+ // invalid after a restart — join() is idempotent per serial, so a client
87
+ // that kept an old token simply re-joins and gets a fresh one.
88
+ tokenFor(id) {
89
+ return createHmac('sha256', this.secret).update('mesh:' + id).digest('base64url').slice(0, 32)
90
+ }
91
+
92
+ verify(id, token) {
93
+ if (typeof id !== 'string' || typeof token !== 'string') return false
94
+ const expected = this.tokenFor(id)
95
+ if (token.length !== expected.length) return false
96
+ let diff = 0
97
+ for (let i = 0; i < expected.length; i++) diff |= token.charCodeAt(i) ^ expected.charCodeAt(i)
98
+ return diff === 0
99
+ }
100
+
101
+ // ---- identity -----------------------------------------------------------
102
+ /**
103
+ * Join (or re-join) the mesh. A serial pins a stable identity: same device,
104
+ * same peer id and same callsign forever. Returns {peer, token}.
105
+ */
106
+ join({ serial, name, role } = {}) {
107
+ const cleanSerial = typeof serial === 'string' && serial.trim() !== '' ? serial.trim() : undefined
108
+ if (cleanSerial) {
109
+ const existing = [...this.peers.values()].find((peer) => peer.serial === cleanSerial)
110
+ if (existing) {
111
+ existing.lastSeen = this.now()
112
+ return { peer: existing, token: this.tokenFor(existing.id) }
113
+ }
114
+ }
115
+ const id = cleanSerial ? 'p-' + hex16('serial:' + cleanSerial) : 'p-' + randomBytes(6).toString('hex')
116
+ const wanted = typeof name === 'string' && /^[A-Za-z0-9 _-]{1,32}$/.test(name.trim())
117
+ ? name.trim().replace(/\s+/g, '-')
118
+ : callsign(cleanSerial ?? id)
119
+ const taken = new Set([...this.peers.values()].map((peer) => peer.name))
120
+ let unique = wanted
121
+ for (let n = 2; taken.has(unique); n++) unique = wanted + '-' + n
122
+ const peer = {
123
+ id,
124
+ name: unique,
125
+ serial: cleanSerial,
126
+ role: typeof role === 'string' && role ? role : 'player',
127
+ joinedAt: this.now(),
128
+ lastSeen: this.now(),
129
+ }
130
+ this.peers.set(id, peer)
131
+ this.inboxes.set(id, [])
132
+ this.#log('join', { peer: peer.name, id, serial: cleanSerial })
133
+ return { peer, token: this.tokenFor(id) }
134
+ }
135
+
136
+ leave(id) {
137
+ const peer = this.peers.get(id)
138
+ if (!peer) return false
139
+ for (const session of this.sessions.values()) {
140
+ session.members = session.members.filter((member) => member !== id)
141
+ if (session.members.length < 2) this.sessions.delete(session.id)
142
+ }
143
+ this.peers.delete(id)
144
+ this.inboxes.delete(id)
145
+ this.#log('leave', { peer: peer.name, id })
146
+ return true
147
+ }
148
+
149
+ /** Resolve a peer by id, callsign (case-insensitive), or serial. */
150
+ resolve(query) {
151
+ if (typeof query !== 'string' || query === '') return undefined
152
+ const direct = this.peers.get(query)
153
+ if (direct) return direct
154
+ const lower = query.toLowerCase()
155
+ return [...this.peers.values()].find(
156
+ (peer) => peer.name.toLowerCase() === lower || peer.serial === query,
157
+ )
158
+ }
159
+
160
+ // ---- sessions -----------------------------------------------------------
161
+ link(queries) {
162
+ const members = []
163
+ for (const query of queries ?? []) {
164
+ const peer = this.resolve(query)
165
+ if (!peer) throw new HubError('unknown_peer', `no mesh peer named "${query}"`)
166
+ if (!members.includes(peer.id)) members.push(peer.id)
167
+ }
168
+ if (members.length < 2) throw new HubError('bad_request', 'a session needs two or more peers')
169
+ if (members.length > 8) throw new HubError('bad_request', 'a session holds at most 8 peers')
170
+ const id = 's-' + randomBytes(4).toString('hex')
171
+ const session = { id, members, policy: makePolicy(), createdAt: this.now() }
172
+ this.sessions.set(id, session)
173
+ this.#log('link', { session: id, peers: members.map((member) => this.peers.get(member).name) })
174
+ return session
175
+ }
176
+
177
+ unlink(sessionId) {
178
+ if (!this.sessions.delete(sessionId)) throw new HubError('unknown_session', `no session "${sessionId}"`)
179
+ this.#log('unlink', { session: sessionId })
180
+ }
181
+
182
+ /** Deliver everything currently matured for a peer with seq > cursor. */
183
+ poll(id, after = 0) {
184
+ const peer = this.peers.get(id)
185
+ if (!peer) throw new HubError('unknown_peer', `no peer "${id}"`)
186
+ peer.lastSeen = this.now()
187
+ const inbox = this.inboxes.get(id) ?? []
188
+ const due = inbox.filter((envelope) => envelope.seq > after && envelope.readyAt <= this.now())
189
+ for (const envelope of due) {
190
+ const index = inbox.indexOf(envelope)
191
+ if (index >= 0) inbox.splice(index, 1)
192
+ }
193
+ const cursor = due.length ? due[due.length - 1].seq : after
194
+ return {
195
+ messages: due.map((envelope) => ({
196
+ seq: envelope.seq,
197
+ from: this.peers.get(envelope.from)?.name ?? envelope.from,
198
+ session: envelope.session,
199
+ ts: envelope.ts,
200
+ body: envelope.body,
201
+ })),
202
+ cursor,
203
+ pending: inbox.length,
204
+ }
205
+ }
206
+
207
+ // ---- messaging ----------------------------------------------------------
208
+ /**
209
+ * Send a JSON message through a session. `from` may be a peer query;
210
+ * `via: 'agent'` marks an observation-window injection. Returns
211
+ * {seq, delivered, dropped, duplicated, readyAt}.
212
+ */
213
+ send({ session: sessionId, from, body, via }) {
214
+ const session = this.sessions.get(sessionId)
215
+ if (!session) throw new HubError('unknown_session', `no session "${sessionId}"`)
216
+ const sender = this.resolve(from)
217
+ if (!sender) throw new HubError('unknown_peer', `no mesh peer named "${from}"`)
218
+ if (!session.members.includes(sender.id)) {
219
+ throw new HubError('not_member', `${sender.name} is not in session ${session.id}`)
220
+ }
221
+ const serialized = JSON.stringify(body ?? null)
222
+ if (serialized === undefined) throw new HubError('bad_request', 'body must be JSON-serializable')
223
+ if (serialized.length > 64 * 1024) throw new HubError('too_large', 'message body exceeds 64 KiB')
224
+ const seq = ++this.messageSeq
225
+ const ts = this.now()
226
+ const policy = session.policy
227
+ const jitter = policy.jitterMs > 0 ? Math.round(this.random() * policy.jitterMs) : 0
228
+ const throttleExtra = policy.throttleKbps > 0 ? Math.round((serialized.length * 8) / policy.throttleKbps) : 0
229
+ let delivered = 0
230
+ let dropped = 0
231
+ let duplicated = 0
232
+ let firstReadyAt = Infinity
233
+ for (const memberId of session.members) {
234
+ if (memberId === sender.id) continue
235
+ if (policy.dropPct > 0 && this.random() * 100 < policy.dropPct) {
236
+ dropped++
237
+ this.#log('drop', { session: session.id, seq, to: this.peers.get(memberId)?.name ?? memberId, from: sender.name })
238
+ continue
239
+ }
240
+ const readyAt = ts + policy.latencyMs + jitter + throttleExtra
241
+ const inbox = this.inboxes.get(memberId) ?? []
242
+ inbox.push({ seq, readyAt, ts, from: sender.id, session: session.id, body })
243
+ this.inboxes.set(memberId, inbox)
244
+ delivered++
245
+ firstReadyAt = Math.min(firstReadyAt, readyAt)
246
+ // Long-poll correctness: a delayed envelope matures without a new send, so
247
+ // arm a one-shot wake for exactly that moment (unref'd; never keeps tests or
248
+ // the host alive).
249
+ const wake = setTimeout(() => this.#wake(memberId), Math.max(0, readyAt - this.now()))
250
+ wake.unref?.()
251
+ if (policy.dupPct > 0 && this.random() * 100 < policy.dupPct) {
252
+ inbox.push({ seq, readyAt, ts, from: sender.id, session: session.id, body })
253
+ duplicated++
254
+ this.#log('dup', { session: session.id, seq, to: this.peers.get(memberId)?.name ?? memberId })
255
+ }
256
+ this.#wake(memberId)
257
+ }
258
+ this.#log('send', {
259
+ session: session.id, seq, from: sender.name, to: delivered, dropped, duplicated,
260
+ bytes: serialized.length, via, latencyMs: policy.latencyMs,
261
+ })
262
+ return {
263
+ seq,
264
+ delivered,
265
+ dropped,
266
+ duplicated,
267
+ readyAt: delivered ? firstReadyAt : null,
268
+ }
269
+ }
270
+
271
+ tune(sessionId, input) {
272
+ const session = this.sessions.get(sessionId)
273
+ if (!session) throw new HubError('unknown_session', `no session "${sessionId}"`)
274
+ session.policy = makePolicy({ ...session.policy, ...input })
275
+ this.#log('tune', { session: session.id, policy: session.policy })
276
+ return session.policy
277
+ }
278
+
279
+ // ---- observation --------------------------------------------------------
280
+ status() {
281
+ return {
282
+ peers: [...this.peers.values()].map((peer) => ({ ...peer })),
283
+ sessions: [...this.sessions.values()].map((session) => ({
284
+ id: session.id,
285
+ members: session.members.map((member) => this.peers.get(member)?.name ?? member),
286
+ policy: { ...session.policy },
287
+ pending: session.members.reduce((sum, member) => sum + (this.inboxes.get(member)?.length ?? 0), 0),
288
+ })),
289
+ messageSeq: this.messageSeq,
290
+ }
291
+ }
292
+
293
+ log({ session, limit = 50 } = {}) {
294
+ const entries = this.entries.filter((entry) => !session || entry.session === session)
295
+ return entries.slice(-clamp(limit, 1, this.maxLog))
296
+ }
297
+
298
+ reset() {
299
+ this.peers.clear()
300
+ this.sessions.clear()
301
+ this.inboxes.clear()
302
+ this.entries.length = 0
303
+ this.messageSeq = 0
304
+ }
305
+
306
+ /** Long-poll support: resolve waiters when mail for `peerId` may have matured. */
307
+ #wake(peerId) {
308
+ for (const waiter of [...this.waiters]) {
309
+ if (waiter.peerId === peerId) {
310
+ const index = this.waiters.indexOf(waiter)
311
+ if (index >= 0) this.waiters.splice(index, 1)
312
+ waiter.resolve()
313
+ }
314
+ }
315
+ }
316
+
317
+ waitFor(peerId, timeoutMs) {
318
+ return new Promise((resolve) => {
319
+ const timer = setTimeout(() => {
320
+ const index = this.waiters.indexOf(waiter)
321
+ if (index >= 0) this.waiters.splice(index, 1)
322
+ resolve(false)
323
+ }, clamp(timeoutMs, 0, 30_000))
324
+ const waiter = {
325
+ peerId,
326
+ resolve: () => { clearTimeout(timer); resolve(true) },
327
+ }
328
+ this.waiters.push(waiter)
329
+ })
330
+ }
331
+
332
+ #log(kind, fields) {
333
+ this.entries.push({ seq: ++this.seq, ts: this.now(), kind, ...fields })
334
+ if (this.entries.length > this.maxLog) this.entries.splice(0, this.entries.length - this.maxLog)
335
+ }
336
+ }
337
+
338
+ export class HubError extends Error {
339
+ constructor(code, message) {
340
+ super(message)
341
+ this.code = code
342
+ }
343
+ }
package/lib/vision.js CHANGED
@@ -117,14 +117,17 @@ export const IMAGE_REF_SCHEMA = {
117
117
  }
118
118
 
119
119
  /**
120
- * Append the image block to a render's content blocks when the value carries an
121
- * `image` ref so an image-capable model SEES the screen. Returns the same
122
- * array for chaining.
120
+ * Append the image block(s) to a render's content blocks when the value carries
121
+ * an `image` ref (or an `images` array of them, as device_pair_capture does)
122
+ * so an image-capable model SEES the screen(s). Returns the same array for
123
+ * chaining.
123
124
  */
124
125
  export function appendImageBlock(blocks, value) {
125
- const image = value?.image
126
- if (image !== undefined && typeof image.attachmentId === 'string') {
127
- blocks.push({ type: 'image', attachment: image })
126
+ const refs = Array.isArray(value?.images) ? value.images : [value?.image]
127
+ for (const image of refs) {
128
+ if (image !== undefined && typeof image.attachmentId === 'string') {
129
+ blocks.push({ type: 'image', attachment: image })
130
+ }
128
131
  }
129
132
  return blocks
130
133
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-mobilecode",
3
- "description": "MobileCode for the dsh web GUI: detect iOS/Android projects, run preview servers, and drive the simulator/emulator from the session — 28 agent tools (device_run, device_screen, device_ui_tree, device_ui_rows, device_tap_row, device_tap_element, device_wait_for, device_scroll_to, device_input, device_intent, device_connect, device_pair_qr, device_perf, device_meminfo, device_backtrace, device_app_info, device_install, device_uninstall, device_reboot, device_log, live screen stream, multimodal screenshots) with one classified adb boundary and a Wi-Fi connect/pair QR flow in the Connection settings tab. Hot-pluggable — mounted via the profile bundle list + cordis.patch.yml, no dsh source changes.",
4
- "version": "0.7.1",
3
+ "description": "MobileCode for the dsh web GUI: detect iOS/Android projects, run preview servers, and drive the simulator/emulator from the session — 37 agent tools (device_run, device_screen, device_ui_tree, device_ui_rows, device_tap_row, device_tap_element, device_wait_for, device_scroll_to, device_input, device_batch, device_intent, device_connect, device_pair_qr, device_perf, device_meminfo, device_backtrace, device_display, device_avd_create, device_pair_capture, device_app_info, device_install, device_uninstall, device_reboot, device_log, live screen stream, multimodal screenshots) plus a host-mediated co-op mesh hub (random callsigns, JSON pub/sub, tunable latency/jitter/drop/dup/throttle) so two virtual devices — and the AI watching them — can talk in real time. One classified adb boundary and a Wi-Fi connect/pair QR flow in the Connection settings tab. Hot-pluggable — mounted via the profile bundle list + cordis.patch.yml, no dsh source changes.",
4
+ "version": "0.8.1",
5
5
  "type": "module",
6
6
  "packageManager": "pnpm@11.22.0",
7
7
  "engines": {
@@ -34,6 +34,7 @@
34
34
  },
35
35
  "files": [
36
36
  "lib/**/*.js",
37
+ "sdk/**/*.mjs",
37
38
  "scripts/**/*.py",
38
39
  "cordis.patch.yml",
39
40
  "README.md"
@@ -0,0 +1,78 @@
1
+ /**
2
+ * dsh-mobilecode mesh — tiny client SDK for co-op game testing (v0.8.0).
3
+ *
4
+ * Plain fetch, no dependencies: runs in Node 18+, Deno, Bun, and any WebView /
5
+ * Expo / React Native JS environment. For non-JS engines (Unity, Godot, plain
6
+ * Kotlin) the protocol is six JSON-over-HTTP endpoints — see README "Co-op
7
+ * mesh". From inside an Android emulator the hub is at
8
+ * http://10.0.2.2:<dsh-port>/api/dsh-mobilecode (default port 3080).
9
+ *
10
+ * Usage:
11
+ * import { MeshClient } from 'dsh-mobilecode/sdk/mesh-client.mjs'
12
+ * const mesh = new MeshClient('http://10.0.2.2:3080/api/dsh-mobilecode')
13
+ * const { peer, token } = await mesh.join({ serial: 'emulator-5554' })
14
+ * const { session } = await mesh.link(['brisk-owl']) // find + link peer by callsign
15
+ * await mesh.send(session.id, { type: 'attack', dmg: 12 })
16
+ * for await (const m of mesh.inbox({ waitMs: 5000 })) console.log(m.from, m.body)
17
+ */
18
+ export class MeshClient {
19
+ constructor(baseUrl = 'http://10.0.2.2:3080/api/dsh-mobilecode') {
20
+ this.base = String(baseUrl).replace(/\/+$/, '')
21
+ this.id = undefined
22
+ this.token = undefined
23
+ this.cursor = 0
24
+ }
25
+
26
+ async #rpc(path, { method = 'GET', body, query } = {}) {
27
+ const url = new URL(this.base + path)
28
+ for (const [key, value] of Object.entries(query ?? {})) if (value !== undefined) url.searchParams.set(key, String(value))
29
+ const res = await fetch(url, {
30
+ method,
31
+ headers: { 'content-type': 'application/json' },
32
+ body: body === undefined ? undefined : JSON.stringify({ id: this.id, token: this.token, ...body }),
33
+ })
34
+ const json = await res.json().catch(() => ({ error: 'non-JSON response' }))
35
+ if (!res.ok) throw Object.assign(new Error(json.error ?? `${method} ${path} → ${res.status}`), { status: res.status, code: json.code })
36
+ return json
37
+ }
38
+
39
+ /** Join (or re-join — idempotent per serial). Stores id + token for later calls. */
40
+ async join(input = {}) {
41
+ const json = await this.#rpc('/mesh/join', { method: 'POST', body: input })
42
+ this.id = json.peer.id
43
+ this.token = json.token
44
+ return json // { ok, peer: { id, name, serial, role, ... }, token }
45
+ }
46
+
47
+ /** Who else is here, and which sessions exist. */
48
+ peers() { return this.#rpc('/mesh/peers') }
49
+
50
+ /** Link this client with the named peers into a new session. */
51
+ link(withPeers) { return this.#rpc('/mesh/link', { method: 'POST', body: { with: withPeers } }) }
52
+
53
+ /** Send a JSON body to every other member of a session. */
54
+ send(session, body) { return this.#rpc('/mesh/send', { method: 'POST', body: { session, body } }) }
55
+
56
+ /** One drain of matured mail; pass waitMs (<=30000) to long-poll. Advances the cursor. */
57
+ async poll({ waitMs } = {}) {
58
+ const json = await this.#rpc('/mesh/poll', { query: { after: this.cursor, ...(waitMs ? { wait: waitMs } : {}) } })
59
+ if (json.messages?.length) this.cursor = json.cursor
60
+ return json // { messages: [{ seq, from, session, ts, body }], cursor, pending }
61
+ }
62
+
63
+ /** Async iterator over incoming messages (long-polls until left). */
64
+ async *inbox({ waitMs = 5000 } = {}) {
65
+ while (this.id) {
66
+ const { messages } = await this.poll({ waitMs })
67
+ for (const message of messages ?? []) yield message
68
+ }
69
+ }
70
+
71
+ /** Leave the mesh; the hub dissolves any session this drops below two members. */
72
+ async leave() {
73
+ const json = await this.#rpc('/mesh/leave', { method: 'POST', body: {} })
74
+ this.id = undefined
75
+ this.token = undefined
76
+ return json
77
+ }
78
+ }