dsh-mobilecode 0.2.1 → 0.4.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.
@@ -0,0 +1,274 @@
1
+ /**
2
+ * dsh-mobilecode — in-process MJPEG-style frame pipeline for one Android device.
3
+ *
4
+ * Ported from ZSeven-W/dsh-android (frame-source.ts, MIT). No external stream
5
+ * helper and no inner loopback port: ONE persistent `adb exec-out` child runs a
6
+ * `screencap -p` loop on the device, this module splits the concatenated PNG
7
+ * output into frames, and the web routes serve the latest frame straight from
8
+ * memory as a `multipart/x-mixed-replace` body (PNG parts — Chromium and
9
+ * Firefox render those exactly like JPEG parts).
10
+ *
11
+ * The persistent child is the heart of the design: spawning adb per frame costs
12
+ * ~200 ms per screenshot (~5 fps ceiling), while one `while :; do screencap -p;
13
+ * done` child streams ~8 fps on an emulator with zero per-frame process cost.
14
+ * The child is intentionally dumb — it exits, this module reports it, and the
15
+ * host controller (android-stream.js) owns restart / keep-alive policy.
16
+ */
17
+
18
+ import * as DeviceBuild from './device-build.js'
19
+
20
+ const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
21
+ const IEND_TYPE = 0x49454e44 // 'IEND'
22
+ /** A screencap frame larger than this means we lost sync; rescan. */
23
+ const MAX_FRAME_BYTES = 64 * 1024 * 1024
24
+ /** Bytes kept while hunting for a signature in garbage (stderr noise, …). */
25
+ const MAX_UNSYNCED_BYTES = 1024 * 1024
26
+ const STDERR_RING_LINES = 20
27
+ const STDERR_LINE_MAX_CHARS = 240
28
+ export const STREAM_BOUNDARY = 'dsh-mobilecode-frame'
29
+
30
+ /** Pixel size of a PNG from its IHDR chunk, without decoding the image. */
31
+ export function pngDimensions(buffer) {
32
+ if (buffer.length < 24) return undefined
33
+ if (!buffer.subarray(0, 8).equals(PNG_SIGNATURE)) return undefined
34
+ if (buffer.readUInt32BE(12) !== 0x49484452) return undefined // 'IHDR'
35
+ return { width: buffer.readUInt32BE(16), height: buffer.readUInt32BE(20) }
36
+ }
37
+
38
+ /**
39
+ * Incremental splitter over a byte stream of back-to-back PNG images.
40
+ *
41
+ * PNG framing is self-describing (8-byte signature, then length-prefixed chunks
42
+ * until IEND), so frames are cut by walking chunk headers — no scanning of image
43
+ * data for markers, no false positives. When the stream derails the splitter
44
+ * drops bytes until the next signature instead of stalling.
45
+ */
46
+ export class PngFrameSplitter {
47
+ #buffer = Buffer.alloc(0)
48
+
49
+ /** Feed bytes; returns every complete PNG that ended inside them. */
50
+ push(chunk) {
51
+ this.#buffer = this.#buffer.length === 0 ? chunk : Buffer.concat([this.#buffer, chunk])
52
+ const frames = []
53
+ for (;;) {
54
+ const start = this.#buffer.indexOf(PNG_SIGNATURE)
55
+ if (start < 0) {
56
+ if (this.#buffer.length > MAX_UNSYNCED_BYTES) {
57
+ this.#buffer = this.#buffer.subarray(this.#buffer.length - PNG_SIGNATURE.length)
58
+ }
59
+ return frames
60
+ }
61
+ if (start > 0) this.#buffer = this.#buffer.subarray(start)
62
+ const end = this.#frameEnd()
63
+ if (end === undefined) {
64
+ if (this.#buffer.length > MAX_FRAME_BYTES) {
65
+ this.#buffer = this.#buffer.subarray(1)
66
+ continue
67
+ }
68
+ return frames
69
+ }
70
+ frames.push(this.#buffer.subarray(0, end))
71
+ this.#buffer = this.#buffer.subarray(end)
72
+ }
73
+ }
74
+
75
+ /** Byte length of the complete PNG at the buffer start, if fully buffered. */
76
+ #frameEnd() {
77
+ let offset = PNG_SIGNATURE.length
78
+ for (;;) {
79
+ if (offset + 8 > this.#buffer.length) return undefined
80
+ const dataLength = this.#buffer.readUInt32BE(offset)
81
+ const type = this.#buffer.readUInt32BE(offset + 4)
82
+ if (dataLength > MAX_FRAME_BYTES) return undefined // corrupt header; caller rescans
83
+ const next = offset + 8 + dataLength + 4
84
+ if (next > this.#buffer.length) return undefined
85
+ if (type === IEND_TYPE) return next
86
+ offset = next
87
+ }
88
+ }
89
+ }
90
+
91
+ /**
92
+ * Owns the one persistent screencap child for one device serial and the
93
+ * latest-frame buffer every consumer reads from.
94
+ */
95
+ export class AdbFrameLoop {
96
+ #child
97
+ #splitter = new PngFrameSplitter()
98
+ #latest
99
+ #sequence = 0
100
+ #stderrRing = []
101
+ #stderrPartial = ''
102
+ #frameWaiters = []
103
+ #stopped = false
104
+
105
+ constructor(serial, events = {}) {
106
+ this.serial = serial
107
+ this.events = events
108
+ }
109
+
110
+ get running() {
111
+ const child = this.#child
112
+ return child !== undefined && child.exitCode === null && child.signalCode === null
113
+ }
114
+
115
+ get latestFrame() {
116
+ return this.#latest
117
+ }
118
+
119
+ get stderrLines() {
120
+ return [...this.#stderrRing]
121
+ }
122
+
123
+ /** Spawn the screencap loop child (idempotent while running). */
124
+ start() {
125
+ if (this.running || this.#stopped) return
126
+ // `exec-out` skips the pty (binary-safe); the single-string command runs
127
+ // through the *device* shell, so one child produces frames forever.
128
+ const child = DeviceBuild.launch(
129
+ DeviceBuild.adb(),
130
+ ['-s', this.serial, 'exec-out', 'while :; do screencap -p; done'],
131
+ { stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true },
132
+ )
133
+ this.#child = child
134
+ child.stdout?.on('data', (chunk) => {
135
+ for (const png of this.#splitter.push(chunk)) this.#acceptFrame(png)
136
+ })
137
+ child.stderr?.on('data', (chunk) => this.#recordStderr(chunk))
138
+ child.once('error', (error) => {
139
+ this.#recordStderr(Buffer.from(`spawn error: ${error.message}\n`))
140
+ })
141
+ child.once('close', (code, signal) => {
142
+ if (this.#child !== child) return
143
+ this.#child = undefined
144
+ const detail = signal !== null ? `killed by ${signal}` : `exit ${String(code)}`
145
+ if (!this.#stopped) this.events.onExit?.(detail)
146
+ })
147
+ }
148
+
149
+ /** Kill the child; the loop object can be started again later. */
150
+ stop() {
151
+ this.#stopped = true
152
+ const child = this.#child
153
+ this.#child = undefined
154
+ if (child !== undefined && child.exitCode === null && child.signalCode === null) {
155
+ child.kill('SIGTERM')
156
+ const hardKill = setTimeout(() => {
157
+ if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL')
158
+ }, 2000)
159
+ hardKill.unref?.()
160
+ }
161
+ const waiters = this.#frameWaiters
162
+ this.#frameWaiters = []
163
+ if (this.#latest !== undefined) for (const waiter of waiters) waiter(this.#latest)
164
+ }
165
+
166
+ /** Allow a stopped loop to be started again (host restart path). */
167
+ reset() {
168
+ this.#stopped = false
169
+ this.#splitter = new PngFrameSplitter()
170
+ }
171
+
172
+ /** The next frame (or the latest one already buffered), bounded in time. */
173
+ waitForFrame(timeoutMs) {
174
+ const latest = this.#latest
175
+ if (latest !== undefined) return Promise.resolve(latest)
176
+ return new Promise((resolve) => {
177
+ let settled = false
178
+ const waiter = (frame) => {
179
+ if (settled) return
180
+ settled = true
181
+ clearTimeout(timer)
182
+ resolve(frame)
183
+ }
184
+ const timer = setTimeout(() => {
185
+ if (settled) return
186
+ settled = true
187
+ const index = this.#frameWaiters.indexOf(waiter)
188
+ if (index >= 0) this.#frameWaiters.splice(index, 1)
189
+ resolve(undefined)
190
+ }, timeoutMs)
191
+ timer.unref?.()
192
+ this.#frameWaiters.push(waiter)
193
+ })
194
+ }
195
+
196
+ #acceptFrame(png) {
197
+ const size = pngDimensions(png)
198
+ if (size === undefined) return
199
+ this.#sequence += 1
200
+ const frame = { png, width: size.width, height: size.height, sequence: this.#sequence, at: Date.now() }
201
+ this.#latest = frame
202
+ const waiters = this.#frameWaiters
203
+ this.#frameWaiters = []
204
+ for (const waiter of waiters) waiter(frame)
205
+ this.events.onFrame?.(frame)
206
+ }
207
+
208
+ #recordStderr(chunk) {
209
+ const text = this.#stderrPartial + chunk.toString('utf8')
210
+ const lines = text.split('\n')
211
+ this.#stderrPartial = lines.pop() ?? ''
212
+ for (const line of lines) {
213
+ const trimmed = line.trimEnd()
214
+ if (trimmed === '') continue
215
+ this.#stderrRing.push(trimmed.length > STDERR_LINE_MAX_CHARS ? `${trimmed.slice(0, STDERR_LINE_MAX_CHARS)}…` : trimmed)
216
+ if (this.#stderrRing.length > STDERR_RING_LINES) this.#stderrRing.shift()
217
+ }
218
+ }
219
+ }
220
+
221
+ /**
222
+ * Write one live multipart/x-mixed-replace response from a frame feed.
223
+ * Backpressure is latest-wins: when the client socket is saturated the writer
224
+ * skips frames instead of queueing them, so a slow tab never builds an
225
+ * unbounded buffer or watches a growing delay.
226
+ */
227
+ export class MultipartFrameWriter {
228
+ #closed = false
229
+ #congested = false
230
+
231
+ constructor(res) {
232
+ this.res = res
233
+ res.writeHead(200, {
234
+ 'content-type': `multipart/x-mixed-replace; boundary=${STREAM_BOUNDARY}`,
235
+ 'cache-control': 'no-cache, no-store',
236
+ 'x-content-type-options': 'nosniff',
237
+ 'cross-origin-resource-policy': 'same-origin',
238
+ 'referrer-policy': 'no-referrer',
239
+ })
240
+ res.on('drain', () => {
241
+ this.#congested = false
242
+ })
243
+ }
244
+
245
+ get closed() {
246
+ return this.#closed
247
+ }
248
+
249
+ /** Write one frame part; silently skipped while the socket is congested. */
250
+ writeFrame(frame) {
251
+ if (this.#closed || this.#congested) return
252
+ const header = `--${STREAM_BOUNDARY}\r\n`
253
+ + 'Content-Type: image/png\r\n'
254
+ + `Content-Length: ${frame.png.length}\r\n\r\n`
255
+ try {
256
+ this.res.write(header)
257
+ const flushed = this.res.write(frame.png)
258
+ this.res.write('\r\n')
259
+ if (!flushed) this.#congested = true
260
+ } catch {
261
+ this.close()
262
+ }
263
+ }
264
+
265
+ close() {
266
+ if (this.#closed) return
267
+ this.#closed = true
268
+ try {
269
+ this.res.end()
270
+ } catch {
271
+ // The socket may already be gone.
272
+ }
273
+ }
274
+ }
package/lib/index.js CHANGED
@@ -18,6 +18,10 @@
18
18
  import { defineTool } from '@deepseek-ai/dsh-tools'
19
19
  import * as DeviceBuild from './device-build.js'
20
20
  import * as UiTree from './uitree.js'
21
+ import * as FrameSource from './frame-source.js'
22
+ import * as StreamAccess from './stream-access.js'
23
+ import { AndroidStreamHost, ROTATION_CYCLE } from './android-stream.js'
24
+ import * as Vision from './vision.js'
21
25
  import { DevicePreviewEngine } from './device-preview.js'
22
26
  import * as Setup from './setup.js'
23
27
  import { registerMobileSkill } from './skill.js'
@@ -70,11 +74,21 @@ function resolveDirectory(body, config) {
70
74
  return process.cwd()
71
75
  }
72
76
 
73
- function makeRoutes(engine, config) {
77
+ function makeRoutes(engine, config, stream) {
74
78
  const guard = (req, res) => {
75
79
  if (!isLoopbackRequest(req)) { writeJson(res, 403, { error: 'forbidden: loopback-only' }); return false }
76
80
  return true
77
81
  }
82
+ // Stronger fence for the stream routes: loopback peer + loopback Host +
83
+ // Sec-Fetch-Site/Origin. POSTs (which mint capabilities) also require Origin.
84
+ const fence = (req, res, requireOrigin) => {
85
+ if (!StreamAccess.isTrustedRequest(req, requireOrigin)) { writeJson(res, 403, { error: 'forbidden: loopback trusted-browser only' }); return false }
86
+ return true
87
+ }
88
+ const isPost = (req, res) => {
89
+ if ((req.method ?? 'GET') !== 'POST') { writeJson(res, 405, { error: 'method not allowed' }); return false }
90
+ return true
91
+ }
78
92
  const platformOf = (value) => (value === 'ios' || value === 'android' ? value : undefined)
79
93
  const routes = [
80
94
  // GET /api/dsh-mobilecode?directory=... → current info (platforms, servers, builds, bundler).
@@ -290,6 +304,154 @@ function makeRoutes(engine, config) {
290
304
  writeJson(res, 405, { error: 'method not allowed' })
291
305
  },
292
306
  },
307
+ // ── live device stream (panel) ──────────────────────────────────────────
308
+ // GET /api/dsh-mobilecode/stream?token=… — live multipart/x-mixed-replace PNG
309
+ // stream from the in-process frame loop. The <img> GET carries no Origin, so
310
+ // the fence here is loopback-only (requireOrigin false).
311
+ {
312
+ kind: 'exact',
313
+ path: API_BASE + '/stream',
314
+ handler: async (req, res) => {
315
+ if (!fence(req, res, false)) return
316
+ if ((req.method ?? 'GET') !== 'GET') { writeJson(res, 405, { error: 'method not allowed' }); return }
317
+ const token = new URL(req.url ?? '/', 'http://localhost').searchParams.get('token') ?? ''
318
+ const payload = await stream.access.verifyStreamToken(token)
319
+ if (payload === undefined) { writeJson(res, 403, { error: 'the stream token is invalid or expired' }); return }
320
+ if (stream.host.streamedSerial !== payload.serial) { writeJson(res, 503, { error: 'the device stream is not running; request a fresh grant' }); return }
321
+ const release = stream.host.acquire()
322
+ try {
323
+ await stream.host.ensureStreaming({ serial: payload.serial })
324
+ } catch (error) {
325
+ release()
326
+ writeJson(res, 502, { error: `the device stream failed to start: ${error instanceof Error ? error.message : String(error)}` })
327
+ return
328
+ }
329
+ const writer = new FrameSource.MultipartFrameWriter(res)
330
+ let finished = false
331
+ const teardown = () => {
332
+ if (finished) return
333
+ finished = true
334
+ unsubscribe()
335
+ writer.close()
336
+ release()
337
+ }
338
+ const unsubscribe = stream.host.subscribeFrames((frame) => {
339
+ // Frames for a different serial (after a device switch) must not leak
340
+ // into a capability minted for the old device.
341
+ if (stream.host.streamedSerial === payload.serial) writer.writeFrame(frame)
342
+ else teardown()
343
+ })
344
+ res.on('error', teardown)
345
+ res.on('close', teardown)
346
+ const latest = stream.host.latestFrame
347
+ if (latest !== undefined) writer.writeFrame(latest)
348
+ },
349
+ },
350
+ // POST /api/dsh-mobilecode/stream/grant {device?} — mint a fresh stream URL.
351
+ // Only starts the loop for an ONLINE device; never boots an emulator, never
352
+ // yanks the stream from a different streaming device.
353
+ {
354
+ kind: 'exact',
355
+ path: API_BASE + '/stream/grant',
356
+ handler: async (req, res) => {
357
+ if (!fence(req, res, true) || !isPost(req, res)) return
358
+ const body = await readBody(req, res)
359
+ if (body === undefined) return
360
+ try {
361
+ const serial = typeof body.device === 'string' && body.device !== '' ? body.device : stream.host.streamedSerial
362
+ if (!serial) { writeJson(res, 409, { error: 'no device is streaming; pass a serial' }); return }
363
+ if (!StreamAccess.SERIAL_PATTERN.test(serial)) { writeJson(res, 400, { error: 'device must be an adb device serial' }); return }
364
+ if (stream.host.streamedSerial !== serial) {
365
+ const online = await stream.host.listDevices()
366
+ if (!online.some((device) => device.serial === serial)) { writeJson(res, 409, { error: `device ${serial} is not online` }); return }
367
+ }
368
+ await stream.host.ensureStreaming({ serial })
369
+ const signed = await stream.access.signStreamToken(serial)
370
+ writeJson(res, 200, { ok: true, streamUrl: `${API_BASE}/stream?token=${encodeURIComponent(signed.token)}`, expiresAt: signed.expiresAt, device: serial })
371
+ } catch (error) {
372
+ writeJson(res, 502, { error: `the device stream failed to start: ${error instanceof Error ? error.message : String(error)}` })
373
+ }
374
+ },
375
+ },
376
+ // POST /api/dsh-mobilecode/stream/status {device?} — read-only snapshot;
377
+ // never starts a stream and never mints tokens.
378
+ {
379
+ kind: 'exact',
380
+ path: API_BASE + '/stream/status',
381
+ handler: async (req, res) => {
382
+ if (!fence(req, res, true) || !isPost(req, res)) return
383
+ const body = await readBody(req, res)
384
+ if (body === undefined) return
385
+ const status = stream.host.status()
386
+ const filter = body.device
387
+ const running = status.running && status.serial !== undefined && (filter === undefined || filter === '' || status.serial === filter)
388
+ if (!running) { writeJson(res, 200, { ok: true, running: false }); return }
389
+ writeJson(res, 200, { ok: true, running: true, serial: status.serial, width: status.width, height: status.height })
390
+ },
391
+ },
392
+ // POST /api/dsh-mobilecode/stream/devices — online device list for the picker.
393
+ {
394
+ kind: 'exact',
395
+ path: API_BASE + '/stream/devices',
396
+ handler: async (req, res) => {
397
+ if (!fence(req, res, true) || !isPost(req, res)) return
398
+ await readBody(req, res)
399
+ try {
400
+ const devices = await stream.host.listDevices()
401
+ const streamed = stream.host.streamedSerial
402
+ writeJson(res, 200, { ok: true, devices: devices.map((device) => ({ ...device, ...(device.serial === streamed ? { streaming: true } : {}) })) })
403
+ } catch (error) {
404
+ writeJson(res, 503, { error: error instanceof Error ? error.message : String(error) })
405
+ }
406
+ },
407
+ },
408
+ // POST /api/dsh-mobilecode/stream/control {device, action} — one control op.
409
+ // tap/drag coordinates are NORMALIZED 0..1 of the streamed frame.
410
+ {
411
+ kind: 'exact',
412
+ path: API_BASE + '/stream/control',
413
+ handler: async (req, res) => {
414
+ if (!fence(req, res, true) || !isPost(req, res)) return
415
+ const body = await readBody(req, res)
416
+ if (body === undefined) return
417
+ const serial = body.device
418
+ if (typeof serial !== 'string' || !StreamAccess.SERIAL_PATTERN.test(serial)) { writeJson(res, 400, { error: 'device must be an adb device serial' }); return }
419
+ const action = body.action
420
+ if (typeof action !== 'object' || action === null || typeof action.kind !== 'string') { writeJson(res, 400, { error: 'action must be an object with a kind' }); return }
421
+ const point = (x, y) => typeof x === 'number' && typeof y === 'number' && x >= 0 && x <= 1 && y >= 0 && y <= 1
422
+ if (action.kind === 'tap' && !point(action.x, action.y)) { writeJson(res, 400, { error: 'tap needs normalized x,y in 0..1' }); return }
423
+ if (action.kind === 'drag' && !(point(action.fromX, action.fromY) && point(action.toX, action.toY))) { writeJson(res, 400, { error: 'drag needs normalized fromX,fromY,toX,toY in 0..1' }); return }
424
+ if (action.kind === 'button' && (typeof action.name !== 'string' || action.name === '')) { writeJson(res, 400, { error: 'button requires a non-empty name' }); return }
425
+ if (action.kind === 'type' && (typeof action.text !== 'string' || action.text === '')) { writeJson(res, 400, { error: 'type requires a non-empty text' }); return }
426
+ if (stream.host.streamedSerial !== serial) {
427
+ const online = await stream.host.listDevices()
428
+ if (!online.some((device) => device.serial === serial)) { writeJson(res, 409, { error: `device ${serial} is not online` }); return }
429
+ }
430
+ const release = stream.host.acquire()
431
+ try {
432
+ let result = { ok: true }
433
+ switch (action.kind) {
434
+ case 'tap': await stream.host.tap(serial, action.x, action.y); break
435
+ case 'drag': await stream.host.drag(serial, { fromX: action.fromX, fromY: action.fromY, toX: action.toX, toY: action.toY, ...(typeof action.durationMs === 'number' ? { duration: Math.min(5, action.durationMs / 1000) } : {}) }); break
436
+ case 'button': await stream.host.button(serial, action.name); break
437
+ case 'type': await stream.host.type(serial, action.text); break
438
+ case 'rotate': {
439
+ const current = await stream.host.getRotation(serial)
440
+ const next = ROTATION_CYCLE[(ROTATION_CYCLE.indexOf(current) + 1) % ROTATION_CYCLE.length]
441
+ await stream.host.rotate(serial, next)
442
+ result = { ok: true, rotation: next }
443
+ break
444
+ }
445
+ default: writeJson(res, 400, { error: `unknown control action ${JSON.stringify(action.kind)}` }); return
446
+ }
447
+ writeJson(res, 200, result)
448
+ } catch (error) {
449
+ writeJson(res, 502, { error: `the device control failed: ${error instanceof Error ? error.message : String(error)}` })
450
+ } finally {
451
+ release()
452
+ }
453
+ },
454
+ },
293
455
  ]
294
456
  return routes
295
457
  }
@@ -908,7 +1070,58 @@ function deviceLaunchAppTool() {
908
1070
  })
909
1071
  }
910
1072
 
911
- function deviceScreenTool(engine) {
1073
+ function deviceStreamTool(host, access) {
1074
+ return defineTool({
1075
+ name: 'device_stream',
1076
+ description: 'Drive the live device screen stream the Devices panel shows. action=start begins the frame loop for an ' +
1077
+ 'online device and returns a signed streamUrl; status reports whether it is running; stop tears it down. This is a ' +
1078
+ 'human-panel feature — agents that just need to see the screen should use device_screen or device_ui_tree instead.',
1079
+ parameters: {
1080
+ action: { type: 'string', enum: ['status', 'start', 'stop'], description: 'What to do (default status).' },
1081
+ serial: { type: 'string', description: 'Device serial (start needs an online device; omit to use the first attached one).' },
1082
+ },
1083
+ output: {
1084
+ schema: {
1085
+ type: 'object',
1086
+ additionalProperties: false,
1087
+ properties: {
1088
+ action: { type: 'string', required: true },
1089
+ running: { type: 'boolean', required: true },
1090
+ serial: { type: 'string' },
1091
+ streamUrl: { type: 'string' },
1092
+ width: { type: 'integer' },
1093
+ height: { type: 'integer' },
1094
+ },
1095
+ },
1096
+ render: (_args, value) => {
1097
+ const v = value ?? { action: 'status', running: false }
1098
+ const text = v.action === 'start' && v.streamUrl
1099
+ ? `Streaming ${v.serial} (${v.width}x${v.height}) — ${v.streamUrl}`
1100
+ : `Stream ${v.action}: running=${v.running}${v.serial ? ` (${v.serial})` : ''}`
1101
+ return [{ type: 'text', text }]
1102
+ },
1103
+ },
1104
+ async execute(args) {
1105
+ const action = args.action ?? 'status'
1106
+ if (action === 'stop') {
1107
+ await host.stop()
1108
+ return { action, running: false }
1109
+ }
1110
+ if (action === 'status') {
1111
+ const s = host.status()
1112
+ return { action, running: s.running, ...(s.serial !== undefined ? { serial: s.serial } : {}), ...(s.width !== undefined ? { width: s.width, height: s.height } : {}) }
1113
+ }
1114
+ const serial = await requireAndroidDevice(args.serial)
1115
+ const online = await host.listDevices()
1116
+ if (!online.some((device) => device.serial === serial)) throw new Error(`device ${serial} is not online; cannot stream it`)
1117
+ const info = await host.ensureStreaming({ serial })
1118
+ const signed = await access.signStreamToken(serial)
1119
+ return { action, running: true, serial: info.serial, width: info.width, height: info.height, streamUrl: `${API_BASE}/stream?token=${encodeURIComponent(signed.token)}` }
1120
+ },
1121
+ })
1122
+ }
1123
+
1124
+ function deviceScreenTool(engine, vision) {
912
1125
  return defineTool({
913
1126
  name: 'device_screen',
914
1127
  description: 'See what is on an attached Android device right now: captures the screen as a PNG file, dumps the UI ' +
@@ -965,6 +1178,7 @@ function deviceScreenTool(engine) {
965
1178
  },
966
1179
  },
967
1180
  ocrError: { type: 'string' },
1181
+ image: Vision.IMAGE_REF_SCHEMA,
968
1182
  },
969
1183
  },
970
1184
  render: (_args, value) => {
@@ -988,10 +1202,13 @@ function deviceScreenTool(engine) {
988
1202
  if (v.ocr.length > 40) lines.push(` … and ${v.ocr.length - 40} more`)
989
1203
  }
990
1204
  if (!v.ui?.length && !v.ocr?.length) lines.push('No text found on screen.')
991
- return [{ type: 'text', text: lines.join('\n') }]
1205
+ const blocks = [{ type: 'text', text: lines.join('\n') }]
1206
+ // When the routed model accepts images, the screenshot rides along as a
1207
+ // real image block so the model SEES the screen (see lib/vision.js).
1208
+ return Vision.appendImageBlock(blocks, v)
992
1209
  },
993
1210
  },
994
- async execute(args) {
1211
+ async execute(args, exec) {
995
1212
  const serial = await requireAndroidDevice(args.serial)
996
1213
  const png = await DeviceBuild.screenCapture(serial, args.directory)
997
1214
  const [ui, foreground, size] = await Promise.all([
@@ -1015,6 +1232,8 @@ function deviceScreenTool(engine) {
1015
1232
  else out.ocrError = 'PaddleOCR returned no text (or failed silently)'
1016
1233
  }
1017
1234
  }
1235
+ const image = await Vision.maybeAttachScreenshot(vision, png, exec)
1236
+ if (image !== undefined) out.image = image
1018
1237
  return out
1019
1238
  },
1020
1239
  })
@@ -1360,6 +1579,8 @@ function guidance() {
1360
1579
  '- device_action: notifications / quick_settings / collapse / lock / wake / assistant / rotate.',
1361
1580
  '- device_boot / device_shutdown: boot an AVD by name and wait for boot / shut an emulator down (refuses physical devices).',
1362
1581
  '- device_apps / device_launch_app: list installed packages (never guess a package name) / launch one by package or unique substring.',
1582
+ '- device_stream: drive the live screen stream the Devices panel shows (status / start an online device / stop). Agents',
1583
+ ' that just need to see the screen should prefer device_screen or device_ui_tree.',
1363
1584
  '- device_log: read device logs (logcat main/crash/events, kernel dmesg). Call it when a run fails or an app misbehaves.',
1364
1585
  '- device_status: one normalized snapshot of attached devices, AVDs, running/parked projects, Metro and preview servers.',
1365
1586
  '',
@@ -1388,8 +1609,12 @@ export function apply(ctx, config) {
1388
1609
  })
1389
1610
 
1390
1611
  const engine = new DevicePreviewEngine()
1612
+ const streamHost = new AndroidStreamHost()
1613
+ const streamAccess = new StreamAccess.StreamAccessController()
1614
+ const vision = Vision.resolveVisionServices(ctx)
1391
1615
  const handle = {
1392
1616
  engine,
1617
+ stream: streamHost,
1393
1618
  status: () => ({
1394
1619
  directories: [...new Set([...engine.builds.keys()].map((key) => key.split('\0')[0]))],
1395
1620
  servers: [...engine.servers.keys()],
@@ -1400,7 +1625,7 @@ export function apply(ctx, config) {
1400
1625
  if (typeof ctx.provide === 'function') ctx.provide('mobilecode', handle)
1401
1626
  else ctx.mobilecode = handle
1402
1627
 
1403
- const routes = makeRoutes(engine, config)
1628
+ const routes = makeRoutes(engine, config, { host: streamHost, access: streamAccess })
1404
1629
  let disposeRoutes
1405
1630
  let disposeTools
1406
1631
  let disposeSection
@@ -1411,6 +1636,7 @@ export function apply(ctx, config) {
1411
1636
  if (disposeRoutes !== undefined) { disposeRoutes(); disposeRoutes = undefined }
1412
1637
  if (disposeTools !== undefined) { disposeTools(); disposeTools = undefined }
1413
1638
  if (!value.enabled) return
1639
+ streamHost.startKeepAlive()
1414
1640
  if (value.announceToAgent) {
1415
1641
  disposeSection = ctx.systemPrompt.section({
1416
1642
  name: 'plugin:dsh-mobilecode',
@@ -1426,7 +1652,7 @@ export function apply(ctx, config) {
1426
1652
  const disposers = [
1427
1653
  deviceRunTool(engine, config),
1428
1654
  deviceDetectTool(engine, config),
1429
- deviceScreenTool(engine),
1655
+ deviceScreenTool(engine, vision),
1430
1656
  deviceUiTreeTool(),
1431
1657
  deviceTapElementTool(),
1432
1658
  deviceWaitForTool(),
@@ -1435,6 +1661,7 @@ export function apply(ctx, config) {
1435
1661
  deviceActionTool(),
1436
1662
  deviceAppsTool(),
1437
1663
  deviceLaunchAppTool(),
1664
+ deviceStreamTool(streamHost, streamAccess),
1438
1665
  deviceLogTool(engine),
1439
1666
  deviceStatusTool(engine),
1440
1667
  deviceInputTool(),
@@ -1445,6 +1672,7 @@ export function apply(ctx, config) {
1445
1672
 
1446
1673
  ctx.effect(() => () => {
1447
1674
  disposeSkill()
1675
+ void streamHost.dispose()
1448
1676
  void engine.dispose()
1449
1677
  }, 'dsh-mobilecode: engine')
1450
1678