Live Android device console: UI-state toggles, Doze and standby controls, WorkManager job runner, and the device state in the system prompt.

A Claude Code mod that puts the attached Android device's live state in front of you and the model.
/device pane:/device reset restores the defaults and takes the device out of forced idle.
android-device-console:device): says which device is targeted and its dark mode, font scale, density, RTL, layout-bounds, animation, Doze and standby state. The model then knows a screenshot was taken under, say, dark mode at font 1.3.set_device_state: dark, fontScale, density or "reset", rtl, layoutBounds, animations, and restartApp.run_worker: force-runs a job with cmd jobscheduler run -f, adding -n <namespace> for a job in a JobScheduler namespace. On API 34+ WorkManager 2.9+ schedules its jobs in androidx.work.systemjobscheduler. Give it a jobId, a workSpecId, or nothing when the app has exactly one job. WorkManager gives the work a new job id each time it is enqueued; when the id it was given is gone, run_worker lists the jobs again and runs the one that took its place, or asks when several could have.set_doze: state idle/off and/or a standby bucket.cmd jobscheduler run through Bash, the mod rewrites the command before it runs. It adds a missing -f, the package, the job id when exactly one job is known, and -n <namespace> when the console knows the job's namespace. It also puts the package before the id. When the device answers "Could not find job" to a run without -n, the model gets a note about WorkManager's namespace. Only a command that adb shell runs is touched; the same words in a script, a grep or a file being written are left alone. It refuses the command, listing the job ids, when it can't tell which job was meant. The model gets a note about each rewrite. The mod never hooks Gradle commands.The mod picks the target in this order:
ANDROID_SERIAL/deviceIf several devices are connected and none was chosen, the tools refuse and ask you to pick one. They never guess. Every adb call passes -s <serial>.
The application id comes from the plugin option package. If that is empty, the mod reads applicationId from app/build.gradle.kts or app/build.gradle. You can also pass package to any tool.
The mod runs adb through $.process when the host provides it. $.process is documented as CLI-only, so the mod checks for it at session start. If it is missing, every adb call goes through the Bash tool, and each call raises a permission prompt. Button presses pass consent so the prompt names what you pressed.
To stop the prompts, add this to the permissions.allow list in ~/.claude/settings.json:
"Bash(adb *)"
When the mod has to use the Bash tool, it never polls: state is re-read only on /device, Refresh, or after an action. With $.process, it re-reads the state every pollSeconds (15 by default; 0 turns polling off).
dumpsys output differs between API levels. The parsers read defensively and show ? for anything they can't tell.cmd locale set-app-locales <pkg> --locales he), so it needs the app's package. The app's activities recreate right-to-left at once; the system UI stays left-to-right. Hebrew is used because an app without pseudoLocalesEnabled drops the ar-XB pseudo-locale from its resources and stays left-to-right. Text stays in the default language unless the app ships Hebrew strings. RTL off, or /device reset, puts back the locales the app had before.debug.force_rtl. Current Android applies that setting only after a configuration change or a reboot, so the tool says when it used this fallback. Pass restartApp: true to relaunch the app.setprop debug.layout plus service call activity 1599295570. The second command can fail silently on some images.dumpsys battery unplug. Unforce or /device reset runs dumpsys battery reset.claude plugin validate .
claude plugin test .hooks/register.tsx 707 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { Device, DeviceState, ExecMode, Job } from '../types'
5import {
6 BUCKET_NAMES,
7 DEFAULTS,
8 type DeviceChanges,
9 PACKAGE,
10 changeScript,
11 describeDevice,
12 dozeScript,
13 fixJobschedulerCommand,
14 isJobMissing,
15 isRtlLocaleList,
16 jobLabel,
17 parseAppLocales,
18 nextDensity,
19 nextFontScale,
20 parseDevices,
21 parseJobs,
22 parseState,
23 pickTarget,
24 readChanges,
25 runJobScript,
26 shellQuote,
27 stateScript,
28 splitSections,
29} from './adb'
30
31type $ = EngineInterface
32
33const PLUGIN = 'android-device-console'
34const PANE = 'device-console'
35const TOOL_SET = 'mcp__android-device-console__set_device_state'
36const TOOL_RUN = 'mcp__android-device-console__run_worker'
37const TOOL_DOZE = 'mcp__android-device-console__set_doze'
38
39const mode = atom({ plugin: 'android-device-console', key: 'mode' } as const, null)
40const adbPath = atom({ plugin: 'android-device-console', key: 'adb' } as const, 'adb')
41const probeNote = atom({ plugin: 'android-device-console', key: 'probeNote' } as const, null)
42const devices = atom({ plugin: 'android-device-console', key: 'devices' } as const, [])
43const chosenSerial = atom({ plugin: 'android-device-console', key: 'chosenSerial' } as const, null)
44const device = atom({ plugin: 'android-device-console', key: 'device' } as const, null)
45const jobs = atom({ plugin: 'android-device-console', key: 'jobs' } as const, [])
46const pkgAtom = atom({ plugin: 'android-device-console', key: 'pkg' } as const, null)
47const busy = atom({ plugin: 'android-device-console', key: 'busy' } as const, null)
48const lastMessage = atom({ plugin: 'android-device-console', key: 'lastMessage' } as const, null)
49const rtlRestore = atom({ plugin: 'android-device-console', key: 'rtlRestore' } as const, null)
50
51type Ran = { ok: boolean; out: string; err: string }
52type Ctx = { serial?: string; consent?: string; pkg?: string }
53
54let configuredPkg: string | null = null
55let pollMs = 15000
56let poller: Timer | null = null
57let isPolling = false
58
59// --- host commands: $.process where the host has it, else the Bash tool --
60
61async function probe($: $): Promise<ExecMode> {
62 const known = await read($, mode)
63 if (known !== null) return known
64 const home = await $.env.get('ANDROID_HOME')
65 const sdkRoot = await $.env.get('ANDROID_SDK_ROOT')
66 const userHome = await $.env.get('HOME')
67 const candidates = [
68 'adb',
69 home && `${home}/platform-tools/adb`,
70 sdkRoot && `${sdkRoot}/platform-tools/adb`,
71 userHome && `${userHome}/Library/Android/sdk/platform-tools/adb`,
72 userHome && `${userHome}/Android/Sdk/platform-tools/adb`,
73 ].filter((c): c is string => Boolean(c))
74 let reason = ''
75 for (const candidate of candidates) {
76 try {
77 const r = await $.process.run([candidate, 'version'], { timeoutMs: 5000 })
78 if (r.exitCode === 0) {
79 await update($, adbPath, () => candidate)
80 await update($, probeNote, () => null)
81 await update($, mode, () => 'process' as const)
82 return 'process'
83 }
84 reason = r.stderr.trim() || `exit ${r.exitCode}`
85 } catch (err) {
86 reason = err instanceof Error ? err.message : String(err)
87 }
88 }
89 await update($, adbPath, () => 'adb')
90 await update($, probeNote, () => `$.process could not run adb here (${reason.slice(0, 160)}); adb runs through the Bash tool.`)
91 await update($, mode, () => 'bash' as const)
92 return 'bash'
93}
94
95async function adb($: $, args: readonly string[], why: string, consent?: string): Promise<Ran> {
96 const how = await probe($)
97 const argv = [await read($, adbPath), ...args]
98 if (how === 'process') {
99 try {
100 const r = await $.process.run(argv, { timeoutMs: 30000 })
101 return { ok: r.exitCode === 0, out: r.stdout, err: r.stderr }
102 } catch (err) {
103 return { ok: false, out: '', err: err instanceof Error ? err.message : String(err) }
104 }
105 }
106 const ran = await $.tool.call({
107 tool: 'Bash',
108 command: shellQuote(argv),
109 description: why,
110 ...(consent ? { consent } : {}),
111 })
112 if (ran.deny !== undefined) return { ok: false, out: '', err: ran.deny }
113 if (ran.isError) return { ok: false, out: '', err: ran.text ?? String(ran.result) }
114 return { ok: true, out: ran.result.stdout, err: ran.result.stderr }
115}
116
117// --- target device and package -------------------------------------------
118
119async function refreshDevices($: $, consent?: string): Promise<Device[]> {
120 const r = await adb($, ['devices', '-l'], 'List connected Android devices', consent)
121 const list = r.ok ? parseDevices(r.out) : []
122 await update($, devices, () => list)
123 return list
124}
125
126async function target($: $, ctx: Ctx): Promise<{ serial: string } | { error: string }> {
127 if (ctx.serial) return { serial: ctx.serial }
128 const env = await $.env.get('ANDROID_SERIAL')
129 if (env) return { serial: env }
130 let list = await read($, devices)
131 if (list.filter(d => d.state === 'device').length === 0) list = await refreshDevices($, ctx.consent)
132 return pickTarget(list, undefined, await read($, chosenSerial))
133}
134
135async function resolvePkg($: $, explicit?: string): Promise<string | null> {
136 if (explicit && PACKAGE.test(explicit)) return explicit
137 if (configuredPkg) return configuredPkg
138 const cached = await read($, pkgAtom)
139 if (cached !== null) return cached
140 for (const file of ['app/build.gradle.kts', 'app/build.gradle']) {
141 try {
142 const text = await $.fs.read(file)
143 const id = /applicationId\s*=?\s*["']([\w.]+)["']/.exec(text)?.[1]
144 if (id) {
145 await update($, pkgAtom, () => id)
146 return id
147 }
148 } catch {
149 // not this file
150 }
151 }
152 return null
153}
154
155async function shell($: $, ctx: Ctx, script: string, why: string): Promise<{ serial: string; ran: Ran } | { error: string }> {
156 const t = await target($, ctx)
157 if ('error' in t) return t
158 const ran = await adb($, ['-s', t.serial, 'shell', script], why, ctx.consent)
159 if (!ran.ok && /not found|offline|unauthorized|no devices/i.test(ran.err)) {
160 await update($, devices, () => [])
161 }
162 return { serial: t.serial, ran }
163}
164
165async function storeState($: $, serial: string, stdout: string, pkg: string | null): Promise<DeviceState> {
166 const state = parseState(serial, stdout, await $.clock.now())
167 await update($, device, () => state)
168 if (pkg !== null) await update($, pkgAtom, () => pkg)
169 return state
170}
171
172// --- actions shared by the tools, the band, the pane and /device ---------
173
174async function withBusy<T>($: $, label: string, work: () => Promise<T>): Promise<T> {
175 await update($, busy, () => label)
176 try {
177 return await work()
178 } finally {
179 await update($, busy, () => null)
180 }
181}
182
183async function refreshState($: $, ctx: Ctx): Promise<string> {
184 const pkg = await resolvePkg($, ctx.pkg)
185 const r = await shell($, ctx, stateScript(pkg), 'Read Android device UI and Doze state')
186 if ('error' in r) return r.error
187 if (!r.ran.ok) return `Reading device state failed: ${r.ran.err.trim()}`
188 const state = await storeState($, r.serial, r.ran.out, pkg)
189 return describeDevice(state, pkg)
190}
191
192async function setDeviceState($: $, changes: DeviceChanges, ctx: Ctx & { restartApp?: boolean }): Promise<{ text: string } | { error: string }> {
193 const pkg = await resolvePkg($, ctx.pkg)
194 const options = { restartPkg: ctx.restartApp ? pkg : null, pkg, restoreLocales: await read($, rtlRestore) }
195 const script = `echo @@out; ${changeScript(changes, options)}; ${stateScript(pkg)}`
196 const r = await shell($, ctx, script, 'Set Android device UI state')
197 if ('error' in r) return r
198 if (!r.ran.ok) return { error: `adb failed: ${r.ran.err.trim() || r.ran.out.trim()}` }
199 const sections = splitSections(r.ran.out)
200 if (changes.rtl === true) {
201 // Keep the app's own locales to put back, unless they are already an RTL one.
202 const before = parseAppLocales(sections.get('rtlprev'))
203 if (before !== null && !isRtlLocaleList(before)) await update($, rtlRestore, () => before)
204 } else if (changes.rtl === false) {
205 await update($, rtlRestore, () => null)
206 }
207 const state = await storeState($, r.serial, r.ran.out, pkg)
208 return { text: `Applied on ${r.serial}.\n${describeDevice(state, pkg)}${rtlNote(changes, sections.get('rtlvia'), ctx.restartApp === true)}` }
209}
210
211function rtlNote(changes: DeviceChanges, via: string | undefined, isRestarted: boolean): string {
212 if (changes.rtl !== true) return ''
213 if (via === 'app-locale') {
214 return (
215 "\nForced RTL through the app's per-app locale (he, Hebrew): its activities recreate right-to-left at once. " +
216 'Text stays in the default language unless the app ships Hebrew strings; system UI stays left-to-right. ' +
217 'RTL off, or reset, puts back the locales the app had.'
218 )
219 }
220 return (
221 '\nForced RTL through the developer setting debug.force_rtl' +
222 (isRestarted ? ' and an app restart' : '') +
223 '. On Android 13+ it takes effect only after a configuration change or a reboot; ' +
224 'with the app package known, API 33+ uses a per-app RTL locale instead, which applies at once.'
225 )
226}
227
228async function setDoze($: $, input: { state?: 'idle' | 'off'; bucket?: string }, ctx: Ctx): Promise<{ text: string } | { error: string }> {
229 const pkg = await resolvePkg($, ctx.pkg)
230 if (input.bucket !== undefined && pkg === null) {
231 return { error: 'A standby bucket needs the application id: pass package, or set it in the plugin options.' }
232 }
233 const script = `echo @@out; ${dozeScript(input.state, input.bucket, pkg)}; ${stateScript(pkg)}`
234 const r = await shell($, ctx, script, 'Change Android Doze or app standby state')
235 if ('error' in r) return r
236 if (!r.ran.ok) return { error: `adb failed: ${r.ran.err.trim() || r.ran.out.trim()}` }
237 const state = await storeState($, r.serial, r.ran.out, pkg)
238 const out = splitSections(r.ran.out).get('out') ?? ''
239 return { text: `${out ? `${out}\n` : ''}${describeDevice(state, pkg)}` }
240}
241
242async function refreshJobs($: $, ctx: Ctx): Promise<{ jobs: Job[]; pkg: string } | { error: string }> {
243 const pkg = await resolvePkg($, ctx.pkg)
244 if (pkg === null) return { error: 'No application id is known: pass package, or set it in the plugin options.' }
245 const r = await shell($, ctx, `dumpsys jobscheduler ${pkg}`, `List scheduled jobs for ${pkg}`)
246 if ('error' in r) return r
247 if (!r.ran.ok) return { error: `dumpsys jobscheduler failed: ${r.ran.err.trim()}` }
248 const list = parseJobs(r.ran.out, pkg)
249 await update($, jobs, () => list)
250 return { jobs: list, pkg }
251}
252
253type RunInput = { jobId?: number; workSpecId?: string; namespace?: string }
254
255async function runWorker($: $, input: RunInput, ctx: Ctx): Promise<{ text: string } | { error: string }> {
256 const pkg = await resolvePkg($, ctx.pkg)
257 if (pkg === null) return { error: 'No application id is known: pass package, or set it in the plugin options.' }
258 const named = (list: readonly Job[]) =>
259 list.filter(job => job.id === input.jobId && (input.namespace === undefined || job.namespace === input.namespace))
260
261 let job: Job | undefined
262 let note = ''
263 if (input.jobId === undefined) {
264 const listed = await refreshJobs($, { ...ctx, pkg })
265 if ('error' in listed) return listed
266 const matches = input.workSpecId ? listed.jobs.filter(one => one.workSpecId === input.workSpecId) : listed.jobs
267 if (matches.length !== 1) return { error: pickError(pkg, listed.jobs, matches.length, input.workSpecId) }
268 job = matches[0]!
269 } else {
270 job = named(await read($, jobs))[0]
271 if (job === undefined) {
272 const listed = await refreshJobs($, { ...ctx, pkg })
273 if ('error' in listed) return listed
274 job = named(listed.jobs)[0]
275 if (job === undefined) {
276 const stale = replacement(listed.jobs, input.jobId)
277 if ('error' in stale) return { error: `${stale.error} ${pickError(pkg, listed.jobs, listed.jobs.length)}` }
278 ;({ job, note } = stale)
279 }
280 }
281 }
282
283 let r = await shell($, ctx, runJobScript(pkg, job), `Force-run job ${job.id} of ${pkg}`)
284 if ('error' in r) return r
285 let out = `${r.ran.out}${r.ran.err}`.trim()
286 if (isJobMissing(out)) {
287 // WorkManager gives the work a new job id each time it is enqueued.
288 const listed = await refreshJobs($, { ...ctx, pkg })
289 if ('error' in listed) return listed
290 const stale = replacement(listed.jobs, job.id, job)
291 if ('error' in stale) return { error: `Job ${jobLabel(job)} did not run: ${out}. ${pickError(pkg, listed.jobs, listed.jobs.length)}` }
292 ;({ job, note } = stale)
293 r = await shell($, ctx, runJobScript(pkg, job), `Force-run job ${job.id} of ${pkg}`)
294 if ('error' in r) return r
295 out = `${r.ran.out}${r.ran.err}`.trim()
296 }
297 if (!r.ran.ok || isJobMissing(out) || /unknown|error|exception/i.test(out)) {
298 return { error: `Job ${jobLabel(job)} did not run: ${out}` }
299 }
300 return {
301 text: `Forced job ${jobLabel(job)} of ${pkg} on ${r.serial}: ${out || 'started'}.${note} Read logcat for the Worker's output.`,
302 }
303}
304
305/** The job that took a gone id's place: the only one left, or the only one like the old. */
306function replacement(list: readonly Job[], goneId: number, like?: Job): { job: Job; note: string } | { error: string } {
307 const same = like ? list.filter(one => one.namespace === like.namespace && one.component === like.component) : []
308 const pick = same.length === 1 ? same[0]! : list.length === 1 ? list[0]! : undefined
309 if (pick === undefined) return { error: `Job ${goneId} is no longer scheduled.` }
310 return {
311 job: pick,
312 note: ` Job ${goneId} is no longer scheduled; ran job ${pick.id}, which took its place (WorkManager gives work a new job id on each enqueue).`,
313 }
314}
315
316function pickError(pkg: string, all: readonly Job[], matched: number, workSpecId?: string): string {
317 const list = all.map(jobLabel).join(', ')
318 return matched === 0
319 ? `No scheduled job for ${pkg}${workSpecId ? ` with WorkSpec ${workSpecId}` : ''}. Jobs: ${list || 'none'}. Enqueue the work first.`
320 : `${matched} jobs are scheduled for ${pkg}: ${list}. Pass jobId (and namespace when two share an id) or workSpecId.`
321}
322
323async function resetAll($: $, ctx: Ctx): Promise<string> {
324 const set = await setDeviceState($, DEFAULTS, ctx)
325 if ('error' in set) return set.error
326 const doze = await setDoze($, { state: 'off' }, ctx)
327 return 'error' in doze ? doze.error : `Reset to defaults.\n${doze.text}`
328}
329
330// --- polling: only through $.process, never through the Bash tool -------
331
332async function pollOnce($: $): Promise<void> {
333 if (isPolling) return
334 isPolling = true
335 try {
336 if ((await read($, mode)) !== 'process') return
337 await refreshDevices($)
338 const t = await target($, {})
339 if ('error' in t) {
340 await update($, device, () => null)
341 return
342 }
343 await refreshState($, { serial: t.serial })
344 } catch {
345 // the next period tries again
346 } finally {
347 isPolling = false
348 }
349}
350
351async function ensurePoller($: $): Promise<void> {
352 if (poller !== null || pollMs === 0) return
353 if ((await probe($)) !== 'process') return
354 poller = $.clock.every(pollMs, () => void pollOnce($))
355}
356
357function field(e: unknown, key: string): unknown {
358 return (e as Record<string, unknown>)[key]
359}
360function str(e: unknown, key: string): string | undefined {
361 const v = field(e, key)
362 return typeof v === 'string' && v ? v : undefined
363}
364
365/** A pane key per job: its id, and its namespace when it has one (ids repeat across namespaces). */
366function jobKey(job: Job): string {
367 return job.namespace ? `${job.id}-${job.namespace}` : String(job.id)
368}
369
370function press(label: string): string {
371 return `The user pressed "${label}" in the Android device console`
372}
373
374async function toggleAction($: $, label: string, changes: DeviceChanges): Promise<void> {
375 const r = await withBusy($, label, () => setDeviceState($, changes, { consent: press(label) }))
376 await update($, lastMessage, () => ('error' in r ? r.error : `${label}: done`))
377}
378
379function toggles($: $, state: DeviceState) {
380 const font = state.fontScale ?? 1
381 const nextDpi = nextDensity(state)
382 return [
383 { key: 'dark', hotkey: '1', label: `Dark ${state.isDark ? 'on' : 'off'}`, changes: { dark: !state.isDark } },
384 { key: 'font', hotkey: '2', label: `Font ${font}×`, changes: { fontScale: nextFontScale(font) } },
385 {
386 key: 'density',
387 hotkey: '3',
388 label: `Density ${state.density ?? '?'}${state.density !== state.physicalDensity ? '*' : ''}`,
389 changes: { density: nextDpi },
390 },
391 { key: 'rtl', hotkey: '4', label: `RTL ${state.isRtl ? 'on' : 'off'}`, changes: { rtl: !state.isRtl } },
392 {
393 key: 'bounds',
394 hotkey: '5',
395 label: `Bounds ${state.hasLayoutBounds ? 'on' : 'off'}`,
396 changes: { layoutBounds: !state.hasLayoutBounds },
397 },
398 {
399 key: 'anims',
400 hotkey: '6',
401 label: `Anims ${state.hasAnimations === false ? 'off' : 'on'}`,
402 changes: { animations: state.hasAnimations === false },
403 },
404 ].map(t => ({ ...t, onPress: () => void toggleAction($, t.label, t.changes) }))
405}
406
407export const register: Register = (on, options) => {
408 configuredPkg = typeof options.package === 'string' && options.package.trim() ? options.package.trim() : null
409 pollMs = typeof options.pollSeconds === 'number' ? Math.max(0, options.pollSeconds) * 1000 : 15000
410 poller = null
411 isPolling = false
412
413 // --- session start: register, probe, first read ---------------------------
414
415 on('session.start', async ($, e, next) => {
416 await $.tool.register({
417 name: 'set_device_state',
418 description:
419 'Set live UI state on the attached Android device or emulator through adb: dark mode, font scale, ' +
420 'display density, forced RTL, layout bounds, animations. Answers the device state afterwards. ' +
421 'Use before judging a screenshot under a configuration (for example dark + font 1.3 + RTL).',
422 isDeferred: false,
423 inputSchema: {
424 type: 'object',
425 properties: {
426 dark: { type: 'boolean', description: 'Night mode on (true) or off (false).' },
427 fontScale: { type: 'number', description: 'System font scale, 0.5 to 3 (1 is default).' },
428 density: {
429 description: 'Display density in dpi, or "reset" for the physical density.',
430 anyOf: [{ type: 'integer' }, { type: 'string', enum: ['reset'] }],
431 },
432 rtl: { type: 'boolean', description: 'Force RTL layout direction.' },
433 layoutBounds: { type: 'boolean', description: 'Show layout bounds.' },
434 animations: { type: 'boolean', description: 'false sets all three animation scales to 0.' },
435 restartApp: { type: 'boolean', description: 'Force-stop and relaunch the app afterwards (needed for RTL).' },
436 serial: { type: 'string', description: 'adb serial; defaults to ANDROID_SERIAL or the one device.' },
437 package: { type: 'string', description: 'applicationId; defaults to the project app.' },
438 },
439 },
440 })
441 await $.tool.register({
442 name: 'run_worker',
443 description:
444 "Force-run a scheduled WorkManager/JobScheduler job of the app (`cmd jobscheduler run -f`, with `-n <namespace>` on API 34+ " +
445 "where WorkManager schedules in androidx.work.systemjobscheduler), ignoring its constraints. With no jobId it lists the app's " +
446 'jobs and runs the only one, or the one whose WorkSpec id matches. Job ids change each time work is enqueued; a gone id is re-resolved.',
447 isDeferred: false,
448 inputSchema: {
449 type: 'object',
450 properties: {
451 jobId: { type: 'integer', description: 'JobScheduler job id.' },
452 workSpecId: { type: 'string', description: 'WorkManager WorkSpec UUID (EXTRA_WORK_SPEC_ID; dumpsys shows it only before API 34).' },
453 namespace: { type: 'string', description: 'JobScheduler namespace, when two jobs share an id. Found from the job list otherwise.' },
454 serial: { type: 'string' },
455 package: { type: 'string' },
456 },
457 },
458 })
459 await $.tool.register({
460 name: 'set_doze',
461 description:
462 "Force the device into Doze deep idle ('idle', also unplugs the battery) or back out ('off'), " +
463 'and/or set the app standby bucket. Answers the Doze and bucket state afterwards.',
464 isDeferred: false,
465 inputSchema: {
466 type: 'object',
467 properties: {
468 state: { type: 'string', enum: ['idle', 'off'] },
469 bucket: { type: 'string', enum: [...BUCKET_NAMES] },
470 serial: { type: 'string' },
471 package: { type: 'string' },
472 },
473 },
474 })
475 await $.command.register({
476 name: 'device',
477 description: 'Android device console: UI toggles, Doze, standby bucket, jobs. `/device reset` restores defaults.',
478 argumentHint: '[reset]',
479 })
480 const started = await next(e)
481 if ((await probe($)) === 'process') {
482 void pollOnce($)
483 await ensurePoller($)
484 }
485 return started
486 })
487
488 // --- /device ---------------------------------------------------------------
489
490 on('command.run', { command: 'device' }, async ($, e) => {
491 const consent = `The user ran /device ${e.args}`.trim()
492 await ensurePoller($)
493 if (e.args.trim() === 'reset') {
494 const text = await withBusy($, 'resetting', () => resetAll($, { consent }))
495 await update($, lastMessage, () => text.split('\n')[0] ?? null)
496 return { text }
497 }
498 await $.ui.open({ id: PANE, title: 'Android device', focus: true })
499 const text = await withBusy($, 'reading', async () => {
500 const state = await refreshState($, { consent })
501 const listed = await refreshJobs($, { consent })
502 const jobLine = 'error' in listed ? listed.error : `${listed.jobs.length} scheduled job(s) for ${listed.pkg}.`
503 return `${state}\n${jobLine}`
504 })
505 const note = await read($, probeNote)
506 return { text: note ? `${text}\n${note}` : text }
507 })
508
509 // --- model tools -----------------------------------------------------------
510
511 on('tool.call', { tool: TOOL_SET }, async ($, e) => {
512 const changes = readChanges(e as Record<string, unknown>)
513 if ('error' in changes) return { deny: changes.error }
514 await ensurePoller($)
515 const r = await withBusy($, 'setting state', () =>
516 setDeviceState($, changes, {
517 serial: str(e, 'serial'),
518 pkg: str(e, 'package'),
519 restartApp: field(e, 'restartApp') === true,
520 }),
521 )
522 return 'error' in r ? { deny: r.error } : { result: r.text }
523 }).catch(() => ({ deny: 'android-device-console: set_device_state failed inside the plugin.' }))
524
525 on('tool.call', { tool: TOOL_RUN }, async ($, e) => {
526 const jobId = field(e, 'jobId')
527 if (jobId !== undefined && !Number.isInteger(jobId)) return { deny: 'jobId must be an integer.' }
528 const r = await withBusy($, 'running job', () =>
529 runWorker(
530 $,
531 { jobId: jobId as number | undefined, workSpecId: str(e, 'workSpecId'), namespace: str(e, 'namespace') },
532 { serial: str(e, 'serial'), pkg: str(e, 'package') },
533 ),
534 )
535 return 'error' in r ? { deny: r.error } : { result: r.text }
536 }).catch(() => ({ deny: 'android-device-console: run_worker failed inside the plugin.' }))
537
538 on('tool.call', { tool: TOOL_DOZE }, async ($, e) => {
539 const state = field(e, 'state')
540 const bucket = field(e, 'bucket')
541 if (state !== undefined && state !== 'idle' && state !== 'off') return { deny: "state must be 'idle' or 'off'." }
542 if (bucket !== undefined && !(BUCKET_NAMES as readonly unknown[]).includes(bucket)) {
543 return { deny: `bucket must be one of ${BUCKET_NAMES.join(', ')}.` }
544 }
545 if (state === undefined && bucket === undefined) return { deny: 'Name state, bucket, or both.' }
546 const r = await withBusy($, 'changing Doze', () =>
547 setDoze(
548 $,
549 { state: state as 'idle' | 'off' | undefined, bucket: bucket as string | undefined },
550 { serial: str(e, 'serial'), pkg: str(e, 'package') },
551 ),
552 )
553 return 'error' in r ? { deny: r.error } : { result: r.text }
554 }).catch(() => ({ deny: 'android-device-console: set_doze failed inside the plugin.' }))
555
556 // --- fix the model's hand-typed `cmd jobscheduler run` ----------------------
557
558 on('tool.call', { tool: 'Bash', command: /\bcmd\s+jobscheduler\s+run\b/ }, async ($, e, next) => {
559 const env = await $.env.get('ANDROID_SERIAL')
560 const ready = (await read($, devices)).filter(d => d.state === 'device')
561 const chosen = await read($, chosenSerial)
562 const fix = fixJobschedulerCommand(e.command, {
563 pkg: await resolvePkg($),
564 jobs: await read($, jobs),
565 serialToInsert: !env && ready.length > 1 && chosen !== null ? chosen : null,
566 })
567 if ('deny' in fix) return { deny: fix.deny }
568 const command = fix.notes.length === 0 ? e.command : fix.command
569 const ran = await next(command === e.command ? e : { ...e, command })
570 if (ran.deny !== undefined) return ran
571 const notes: string[] = []
572 if (fix.notes.length > 0) notes.push(`android-device-console rewrote the command (${fix.notes.join('; ')}): ${command}`)
573 const said = `${ran.text ?? ''}\n${ran.isError ? String(ran.result ?? '') : ''}`
574 if (isJobMissing(said) && !/\s(-n|--namespace)\s/.test(command)) {
575 notes.push(
576 'android-device-console: on API 34+ WorkManager 2.9+ schedules its jobs in the JobScheduler namespace ' +
577 'androidx.work.systemjobscheduler, and the job id changes on each enqueue. Retry with ' +
578 '`-n androidx.work.systemjobscheduler`, or call mcp__android-device-console__run_worker, which resolves both.',
579 )
580 }
581 return notes.length === 0 ? ran : { ...ran, context: [...(ran.context ?? []), ...notes] }
582 }).catch(($, e, next) => next(e))
583
584 // --- system prompt: the device state the model judges UI under ------------
585
586 on('prompt.compose', async ($, e, next) => {
587 const composed = await next(e)
588 const state = await read($, device)
589 if (state === null) return composed
590 const section = { id: `${PLUGIN}:device`, text: describeDevice(state, await read($, pkgAtom)), scope: 'session' as const }
591 return { sections: [...composed.sections, section] }
592 })
593
594 // --- drawing ---------------------------------------------------------------
595
596 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
597 const state = await read($, device)
598 if (e.props.hasSurvey || state === null) return next(e)
599 const working = await read($, busy)
600 const { Box, Text, Button } = $.ui.resolve(e)
601 return (
602 <Box flexDirection="row" flexWrap="wrap" columnGap={1}>
603 <Text dimColor>{state.model ?? state.serial}{working ? ` · ${working}…` : ''}</Text>
604 {toggles($, state).map(t => (
605 <Button key={t.key} hotkey={t.hotkey} label={t.label} onPress={t.onPress} />
606 ))}
607 </Box>
608 )
609 })
610
611 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
612 const els = $.ui.resolve(e)
613 const { Box, Text, Button } = els
614 const state = await read($, device)
615 const list = await read($, devices)
616 const chosen = await read($, chosenSerial)
617 const jobList = await read($, jobs)
618 const pkg = await read($, pkgAtom)
619 const working = await read($, busy)
620 const message = await read($, lastMessage)
621 const note = await read($, probeNote)
622 const ready = list.filter(d => d.state === 'device')
623 const consent = (label: string) => press(label)
624
625 const refresh = () =>
626 void withBusy($, 'reading', async () => {
627 await refreshDevices($, consent('Refresh'))
628 await refreshState($, { consent: consent('Refresh') })
629 await refreshJobs($, { consent: consent('Refresh') })
630 })
631 const doze = (label: string, input: { state?: 'idle' | 'off'; bucket?: string }) =>
632 void withBusy($, label, async () => {
633 const r = await setDoze($, input, { consent: consent(label) })
634 await update($, lastMessage, () => ('error' in r ? r.error : `${label}: done`))
635 })
636 const run = (job: Job) =>
637 void withBusy($, `running ${job.id}`, async () => {
638 const r = await runWorker($, { jobId: job.id, namespace: job.namespace ?? undefined }, { consent: consent(`Run job ${job.id}`) })
639 await update($, lastMessage, () => ('error' in r ? r.error : r.text))
640 })
641
642 const Select = 'Select' in els ? els.Select : null
643
644 return (
645 <Box flexDirection="column" rowGap={1}>
646 <Box flexDirection="row" columnGap={1} flexWrap="wrap">
647 <Text bold>{state ? `${state.model ?? ''} ${state.serial}${state.api ? ` · API ${state.api}` : ''}` : 'No device read yet'}</Text>
648 {working ? <Text dimColor>{working}…</Text> : null}
649 <Button key="refresh" hotkey="r" label="Refresh" onPress={refresh} />
650 </Box>
651 {ready.length > 1 && Select ? (
652 <Select
653 key="serial"
654 label="Target"
655 value={chosen ?? undefined}
656 options={ready.map(d => ({ value: d.serial, label: d.model ? `${d.serial} ${d.model}` : d.serial }))}
657 onSelect={value => void update($, chosenSerial, () => value).then(refresh)}
658 />
659 ) : null}
660 {state ? (
661 <Box flexDirection="row" columnGap={1} flexWrap="wrap">
662 {toggles($, state).map(t => (
663 <Button key={t.key} hotkey={t.hotkey} label={t.label} onPress={t.onPress} />
664 ))}
665 <Button key="reset" label="Reset all" onPress={() => void withBusy($, 'resetting', async () => {
666 const text = await resetAll($, { consent: consent('Reset all') })
667 await update($, lastMessage, () => text.split('\n')[0] ?? null)
668 })} />
669 </Box>
670 ) : null}
671 <Text bold>Doze and standby{pkg ? ` · ${pkg}` : ''}</Text>
672 <Text dimColor>
673 {state ? `deep ${state.deepIdle ?? '?'} · light ${state.lightIdle ?? '?'} · bucket ${state.bucket ?? '?'}` : 'unknown'}
674 </Text>
675 <Box flexDirection="row" columnGap={1} flexWrap="wrap">
676 <Button key="idle" hotkey="i" label="Force idle" onPress={() => doze('Force idle', { state: 'idle' })} />
677 <Button key="unforce" hotkey="u" label="Unforce" onPress={() => doze('Unforce', { state: 'off' })} />
678 {Select && pkg ? (
679 <Select
680 key="bucket"
681 label="Bucket"
682 value={state?.bucket && (BUCKET_NAMES as readonly string[]).includes(state.bucket) ? state.bucket : undefined}
683 options={BUCKET_NAMES.map(b => ({ value: b }))}
684 onSelect={value => doze(`Bucket ${value}`, { bucket: value })}
685 />
686 ) : null}
687 </Box>
688 <Text bold>Scheduled jobs</Text>
689 {jobList.length === 0 ? <Text dimColor>None listed. Refresh after enqueueing work.</Text> : null}
690 {jobList.map(job => (
691 <Box key={`job-${jobKey(job)}`} flexDirection="row" columnGap={1}>
692 <Button key={`run-${jobKey(job)}`} label={`Run ${job.id}`} onPress={() => run(job)} />
693 <Text wrap="truncate-end">
694 {job.component.split('/').pop()}
695 {job.namespace === 'androidx.work.systemjobscheduler' ? ' · WorkManager' : job.namespace ? ` · ${job.namespace}` : ''}
696 {job.workSpecId ? ` · ${job.workSpecId.slice(0, 8)}` : ''}
697 {job.unsatisfied ? ` · waiting on ${job.unsatisfied}` : ''}
698 </Text>
699 </Box>
700 ))}
701 {message ? <Text dimColor>{message}</Text> : null}
702 {note ? <Text dimColor>{note}</Text> : null}
703 </Box>
704 )
705 })
706}
707hooks/adb.ts 571 lines1// Pure helpers: adb argv and device-shell scripts, and parsers for what the
2// device prints. dumpsys/settings output differs by API level, so every
3// parser reads defensively and answers null for what it cannot tell.
4
5import type { Device, DeviceState, Job } from '../types'
6
7export const SAFE_WORD = /^[\w@%+=:,./-]+$/
8
9export function shellQuote(argv: readonly string[]): string {
10 return argv
11 .map(arg => (SAFE_WORD.test(arg) ? arg : `'${arg.replaceAll("'", `'\\''`)}'`))
12 .join(' ')
13}
14
15export const PACKAGE = /^[A-Za-z][\w]*(\.[A-Za-z_][\w]*)+$/
16
17// --- devices --------------------------------------------------------------
18
19export function parseDevices(stdout: string): Device[] {
20 const devices: Device[] = []
21 for (const raw of stdout.split('\n')) {
22 const line = raw.trim()
23 if (line === '' || line.startsWith('List of devices') || line.startsWith('*')) continue
24 const [serial, state] = line.split(/\s+/)
25 if (serial === undefined || state === undefined) continue
26 const model = /\bmodel:(\S+)/.exec(line)?.[1] ?? null
27 devices.push({ serial, state, model })
28 }
29 return devices
30}
31
32export type Target = { serial: string } | { error: string }
33
34export function pickTarget(
35 devices: readonly Device[],
36 envSerial: string | undefined,
37 chosen: string | null,
38): Target {
39 if (envSerial) return { serial: envSerial }
40 const ready = devices.filter(d => d.state === 'device')
41 if (chosen !== null && ready.some(d => d.serial === chosen)) return { serial: chosen }
42 if (ready.length === 1) return { serial: ready[0]!.serial }
43 if (ready.length === 0) {
44 const other = devices.map(d => `${d.serial} (${d.state})`).join(', ')
45 return {
46 error: other
47 ? `No device is ready: ${other}.`
48 : 'No device is connected (adb devices lists none).',
49 }
50 }
51 const list = ready.map(d => d.serial + (d.model ? ` ${d.model}` : '')).join(', ')
52 return {
53 error: `${ready.length} devices are connected (${list}). Pick one in /device, pass serial, or set ANDROID_SERIAL.`,
54 }
55}
56
57// --- device state ---------------------------------------------------------
58
59export function stateScript(pkg: string | null): string {
60 const parts = [
61 'echo @@model; getprop ro.product.model',
62 'echo @@api; getprop ro.build.version.sdk',
63 'echo @@night; cmd uimode night',
64 'echo @@font; settings get system font_scale',
65 'echo @@density; wm density',
66 'echo @@rtl; settings get global debug.force_rtl',
67 'echo @@layout; getprop debug.layout',
68 'echo @@anim; settings get global animator_duration_scale',
69 'echo @@deep; dumpsys deviceidle get deep',
70 'echo @@light; dumpsys deviceidle get light',
71 ]
72 if (pkg !== null && PACKAGE.test(pkg)) {
73 parts.push(`echo @@bucket; am get-standby-bucket ${pkg}`, `echo @@applocales; cmd locale get-app-locales ${pkg} 2>&1`)
74 }
75 return parts.join('; ')
76}
77
78export function splitSections(stdout: string): Map<string, string> {
79 const sections = new Map<string, string>()
80 let current = ''
81 let lines: string[] = []
82 const flush = () => {
83 if (current) sections.set(current, lines.join('\n').trim())
84 }
85 for (const line of stdout.replaceAll('\r', '').split('\n')) {
86 const marker = /^@@(\w+)$/.exec(line.trim())
87 if (marker) {
88 flush()
89 current = marker[1]!
90 lines = []
91 } else {
92 lines.push(line)
93 }
94 }
95 flush()
96 return sections
97}
98
99const BUCKETS: Record<string, string> = {
100 '5': 'exempted',
101 '10': 'active',
102 '20': 'working_set',
103 '30': 'frequent',
104 '40': 'rare',
105 '45': 'restricted',
106 '50': 'never',
107}
108
109export const BUCKET_NAMES = ['active', 'working_set', 'frequent', 'rare', 'restricted'] as const
110
111function isUnset(text: string | undefined): boolean {
112 return text === undefined || text === '' || text === 'null'
113}
114
115function looksLikeError(text: string): boolean {
116 return /unknown|not found|error|exception|usage:|permission denial/i.test(text)
117}
118
119/** Languages written right to left (`ar-XB`, Android's RTL pseudo-locale, included). */
120const RTL_LANGUAGES = /^(ar|fa|he|iw|ur|yi|ps|ckb|dv|ug|sd)([-_]|$)/i
121
122/**
123 * The RTL locale set as the app's own (per-app) locale to force RTL on API 33+.
124 * Not the `ar-XB` pseudo-locale: an app without `pseudoLocalesEnabled` drops it
125 * from its resources, so the configuration says ldrtl while the UI stays LTR
126 * (seen on an API 35 emulator). Hebrew keeps Latin digits, and an app with no
127 * Hebrew strings keeps its default text.
128 */
129export const RTL_LOCALE = 'he'
130
131const LOCALE_LIST = /^[A-Za-z0-9_,-]*$/
132
133/** `Locales for <pkg> for user 0 are [ar-XB,en-US]` -> `ar-XB,en-US`; null when unreadable. */
134export function parseAppLocales(text: string | undefined): string | null {
135 const list = text === undefined ? undefined : /\bare \[([^\]]*)\]/.exec(text)?.[1]
136 return list === undefined ? null : list.replace(/\s+/g, '')
137}
138
139export function isRtlLocaleList(list: string | null): boolean {
140 return list !== null && RTL_LANGUAGES.test(list.split(',')[0] ?? '')
141}
142
143function firstWord(text: string | undefined): string | null {
144 if (text === undefined || text === '' || looksLikeError(text)) return null
145 return text.split(/\s+/)[0] ?? null
146}
147
148export function parseState(serial: string, stdout: string, readAt: number): DeviceState {
149 const s = splitSections(stdout)
150 const night = s.get('night') ?? ''
151 const font = s.get('font')
152 const density = s.get('density') ?? ''
153 const physical = /Physical density:\s*(\d+)/i.exec(density)?.[1]
154 const override = /Override density:\s*(\d+)/i.exec(density)?.[1]
155 const rtl = s.get('rtl')
156 const appLocales = parseAppLocales(s.get('applocales'))
157 const layout = s.get('layout')
158 const anim = s.get('anim')
159 const bucketRaw = s.get('bucket')
160 const api = Number.parseInt(s.get('api') ?? '', 10)
161 const fontScale = isUnset(font) ? 1 : Number.parseFloat(font!)
162 const animScale = isUnset(anim) ? 1 : Number.parseFloat(anim!)
163 let bucket: string | null = null
164 if (bucketRaw !== undefined && bucketRaw !== '' && !looksLikeError(bucketRaw)) {
165 const word = bucketRaw.split(/\s+/)[0]!
166 bucket = BUCKETS[word] ?? word.toLowerCase()
167 }
168
169 return {
170 serial,
171 model: s.get('model') || null,
172 api: Number.isFinite(api) ? api : null,
173 isDark: /night mode:\s*yes/i.test(night) ? true : /night mode:\s*no/i.test(night) ? false : null,
174 fontScale: Number.isFinite(fontScale) ? fontScale : null,
175 density: override ? Number(override) : physical ? Number(physical) : null,
176 physicalDensity: physical ? Number(physical) : null,
177 isRtl: rtl === undefined && appLocales === null ? null : rtl?.trim() === '1' || isRtlLocaleList(appLocales),
178 appLocales,
179 hasLayoutBounds: layout === undefined ? null : layout.trim() === 'true',
180 hasAnimations: Number.isFinite(animScale) ? animScale !== 0 : null,
181 deepIdle: firstWord(s.get('deep')),
182 lightIdle: firstWord(s.get('light')),
183 bucket,
184 readAt,
185 }
186}
187
188// --- changes --------------------------------------------------------------
189
190export type DeviceChanges = {
191 dark?: boolean
192 fontScale?: number
193 density?: number | 'reset'
194 rtl?: boolean
195 layoutBounds?: boolean
196 animations?: boolean
197}
198
199export function readChanges(input: Record<string, unknown>): DeviceChanges | { error: string } {
200 const changes: DeviceChanges = {}
201 const bool = (key: string): boolean | undefined | 'bad' => {
202 const v = input[key]
203 if (v === undefined || v === null) return undefined
204 return typeof v === 'boolean' ? v : 'bad'
205 }
206 for (const key of ['dark', 'rtl', 'layoutBounds', 'animations'] as const) {
207 const v = bool(key)
208 if (v === 'bad') return { error: `${key} must be true or false.` }
209 if (v !== undefined) changes[key] = v
210 }
211 const font = input.fontScale
212 if (font !== undefined && font !== null) {
213 if (typeof font !== 'number' || !(font >= 0.5 && font <= 3)) {
214 return { error: 'fontScale must be a number from 0.5 to 3.' }
215 }
216 changes.fontScale = font
217 }
218 const density = input.density
219 if (density !== undefined && density !== null) {
220 if (density === 'reset') changes.density = 'reset'
221 else if (typeof density === 'number' && Number.isInteger(density) && density >= 72 && density <= 1000) {
222 changes.density = density
223 } else return { error: 'density must be an integer dpi from 72 to 1000, or "reset".' }
224 }
225 if (Object.keys(changes).length === 0) {
226 return { error: 'Name at least one of dark, fontScale, density, rtl, layoutBounds, animations.' }
227 }
228 return changes
229}
230
231export type ChangeOptions = {
232 /** Force-stop and relaunch this app afterwards. */
233 restartPkg?: string | null
234 /** The app whose per-app locale carries forced RTL on API 33+. */
235 pkg?: string | null
236 /** The app's own locales to put back when RTL goes off (`''` for none). */
237 restoreLocales?: string | null
238}
239
240const SDK_33 = '[ "$(getprop ro.build.version.sdk)" -ge 33 ]'
241
242// Forced RTL: `debug.force_rtl` is applied only on a configuration change or a
243// reboot on current Android (an app restart is not enough on API 35), so on
244// API 33+ the app gets an RTL per-app locale instead, which recreates its
245// activities at once. Without the app's package, or below API 33, the
246// developer setting is the fallback. `@@rtlvia` says which one ran.
247function rtlScript(on: boolean, pkg: string | null, restore: string | null): string[] {
248 const hasPkg = pkg !== null && PACKAGE.test(pkg)
249 const legacy = (v: 0 | 1) => `settings put global debug.force_rtl ${v}; setprop debug.force_rtl ${v} 2>/dev/null`
250 if (on) {
251 if (!hasPkg) return [legacy(1), 'echo @@rtlvia; echo force-rtl']
252 return [
253 `if ${SDK_33}; then echo @@rtlprev; cmd locale get-app-locales ${pkg}; ` +
254 `cmd locale set-app-locales ${pkg} --locales ${RTL_LOCALE}; echo @@rtlvia; echo app-locale; ` +
255 `else ${legacy(1)}; echo @@rtlvia; echo force-rtl; fi`,
256 ]
257 }
258 const cmds = [legacy(0)]
259 if (hasPkg) {
260 const back = restore !== null && restore !== '' && LOCALE_LIST.test(restore) ? ` --locales ${restore}` : ''
261 // Only an RTL per-app locale is taken back: one the person chose stays.
262 cmds.push(
263 `if ${SDK_33} && cmd locale get-app-locales ${pkg} | grep -qE "\\[(ar|fa|he|iw|ur|yi|ps|ckb|dv|ug|sd)[]_,-]"; ` +
264 `then cmd locale set-app-locales ${pkg}${back}; fi`,
265 )
266 }
267 return cmds
268}
269
270export function changeScript(changes: DeviceChanges, options: ChangeOptions = {}): string {
271 const cmds: string[] = []
272 if (changes.dark !== undefined) cmds.push(`cmd uimode night ${changes.dark ? 'yes' : 'no'}`)
273 if (changes.fontScale !== undefined) cmds.push(`settings put system font_scale ${changes.fontScale}`)
274 if (changes.density !== undefined) {
275 cmds.push(changes.density === 'reset' ? 'wm density reset' : `wm density ${changes.density}`)
276 }
277 if (changes.rtl !== undefined) cmds.push(...rtlScript(changes.rtl, options.pkg ?? null, options.restoreLocales ?? null))
278 if (changes.layoutBounds !== undefined) {
279 cmds.push(
280 `setprop debug.layout ${changes.layoutBounds ? 'true' : 'false'}`,
281 'service call activity 1599295570 >/dev/null 2>&1',
282 )
283 }
284 if (changes.animations !== undefined) {
285 const v = changes.animations ? 1 : 0
286 for (const key of ['window_animation_scale', 'transition_animation_scale', 'animator_duration_scale']) {
287 cmds.push(`settings put global ${key} ${v}`)
288 }
289 }
290 const restart = options.restartPkg ?? null
291 if (restart !== null && PACKAGE.test(restart)) {
292 cmds.push(
293 `am force-stop ${restart}`,
294 `monkey -p ${restart} -c android.intent.category.LAUNCHER 1 >/dev/null 2>&1`,
295 )
296 }
297 return cmds.join('; ')
298}
299
300export const DEFAULTS: DeviceChanges = {
301 dark: false,
302 fontScale: 1,
303 density: 'reset',
304 rtl: false,
305 layoutBounds: false,
306 animations: true,
307}
308
309export function dozeScript(state: 'idle' | 'off' | undefined, bucket: string | undefined, pkg: string | null): string {
310 const cmds: string[] = []
311 if (state === 'idle') {
312 cmds.push('dumpsys deviceidle enable', 'dumpsys battery unplug', 'dumpsys deviceidle force-idle')
313 }
314 if (state === 'off') cmds.push('dumpsys deviceidle unforce', 'dumpsys battery reset')
315 if (bucket !== undefined && pkg !== null) cmds.push(`am set-standby-bucket ${pkg} ${bucket}`)
316 return cmds.join('; ')
317}
318
319// --- cycles the band steps through ----------------------------------------
320
321export const FONT_STEPS = [1, 1.15, 1.3, 2] as const
322
323export function nextFontScale(current: number | null): number {
324 const now = current ?? 1
325 return FONT_STEPS.find(step => step > now + 0.001) ?? 1
326}
327
328export function nextDensity(state: DeviceState): number | 'reset' {
329 const physical = state.physicalDensity ?? state.density
330 if (physical === null) return 'reset'
331 const steps = [Math.round(physical * 1.15), Math.round(physical * 1.3), Math.round(physical * 0.85)]
332 const now = state.density ?? physical
333 if (now === physical) return steps[0]!
334 const at = steps.indexOf(now)
335 return at === -1 || at === steps.length - 1 ? 'reset' : steps[at + 1]!
336}
337
338// --- jobs -----------------------------------------------------------------
339
340// `JOB #u0a190/7: 4d5e6f7 com.example/.Svc` before API 34, and from API 34 a job
341// in a JobScheduler namespace (WorkManager 2.9+ uses one) reads
342// `JOB androidx.work.systemjobscheduler:u0a215/2: 67db562 @androidx.work.systemjobscheduler@com.example/...`.
343const JOB_LINE = /JOB (?:#|([^\s:#]+):)[^\s:/]*\/(-?\d+):\s+\S+\s+(?:@([^@\s]+)@)?([\w.]+)\/(\S+)/
344
345/** WorkManager's JobScheduler namespace on API 34+. */
346export const WORKMANAGER_NAMESPACE = 'androidx.work.systemjobscheduler'
347
348export function parseJobs(stdout: string, pkg: string | null): Job[] {
349 const jobs = new Map<string, Job>()
350 let current: Job | null = null
351 for (const raw of stdout.replaceAll('\r', '').split('\n')) {
352 const job = JOB_LINE.exec(raw)
353 if (job) {
354 const id = Number(job[2])
355 const namespace = job[1] ?? job[3] ?? null
356 const owner = job[4]!
357 if (pkg !== null && owner !== pkg) {
358 current = null
359 continue
360 }
361 const key = `${namespace ?? ''}/${id}`
362 current = jobs.get(key) ?? {
363 id,
364 namespace,
365 component: `${owner}/${job[5]}`,
366 workSpecId: null,
367 required: null,
368 unsatisfied: null,
369 }
370 jobs.set(key, current)
371 continue
372 }
373 if (current === null) continue
374 if (raw.trim() !== '' && !/^\s/.test(raw)) {
375 current = null
376 continue
377 }
378 const required = /Required constraints:\s*(.*)$/.exec(raw)?.[1]
379 if (required !== undefined && current.required === null) current.required = required.trim() || null
380 const unsatisfied = /Unsatisfied constraints:\s*(.*)$/.exec(raw)?.[1]
381 if (unsatisfied !== undefined && current.unsatisfied === null) {
382 current.unsatisfied = unsatisfied.trim() || null
383 }
384 const spec = /EXTRA_WORK_SPEC_ID=([\w-]+)/.exec(raw)?.[1]
385 if (spec !== undefined && current.workSpecId === null) current.workSpecId = spec
386 }
387 return [...jobs.values()]
388}
389
390/** The force-run command for one job, `-n` naming its namespace when it has one. */
391export function runJobScript(pkg: string, job: { id: number; namespace: string | null }): string {
392 return ['cmd jobscheduler run -f', job.namespace ? `-n ${job.namespace}` : '', pkg, String(job.id)]
393 .filter(Boolean)
394 .join(' ')
395}
396
397export function jobLabel(job: Job): string {
398 const extra = [
399 job.namespace ? `namespace ${job.namespace}` : null,
400 job.workSpecId ? `WorkSpec ${job.workSpecId}` : null,
401 ].filter(Boolean)
402 return `${job.id}${extra.length > 0 ? ` (${extra.join(', ')})` : ''}`
403}
404
405/** True when `cmd jobscheduler run` answered that the job does not exist. */
406export function isJobMissing(output: string): boolean {
407 return /Could not find job|no such job/i.test(output)
408}
409
410// --- the jobscheduler command fixer ----------------------------------------
411
412export type FixContext = {
413 pkg: string | null
414 jobs: readonly Job[]
415 serialToInsert: string | null
416}
417
418export type Fix = { command: string; notes: string[] } | { deny: string }
419
420const RUN = /\bcmd\s+jobscheduler\s+run\b/g
421const SEGMENT_END = /[;&|'"\n)`]/
422/** `adb [-s X] shell ` (optionally opening a quote) right before the match. */
423const ADB_SHELL_BEFORE = /\badb\b[^;&|\n]*?\bshell\s+["']?\s*$/
424
425export function fixJobschedulerCommand(command: string, ctx: FixContext): Fix {
426 const notes: string[] = []
427 let out = ''
428 let from = 0
429 for (const match of command.matchAll(RUN)) {
430 const start = match.index
431 if (!ADB_SHELL_BEFORE.test(command.slice(0, start))) continue
432 const afterRun = start + match[0].length
433 const rest = command.slice(afterRun)
434 const end = rest.search(SEGMENT_END)
435 const tail = end === -1 ? rest : rest.slice(0, end)
436 const trailing = /\s*$/.exec(tail)?.[0] ?? ''
437 const tokens = tail.trim().split(/\s+/).filter(Boolean)
438
439 let isForced = false
440 let isSatisfied = false
441 let hasNamespace = false
442 const flags: string[] = []
443 const positional: string[] = []
444 let isUnknown = false
445 for (let i = 0; i < tokens.length; i++) {
446 const t = tokens[i]!
447 if (t === '-f' || t === '--force') isForced = true
448 else if (t === '-s' || t === '--satisfied') isSatisfied = true
449 else if (['-u', '--user', '-n', '--namespace'].includes(t) && tokens[i + 1] !== undefined) {
450 if (t === '-n' || t === '--namespace') hasNamespace = true
451 flags.push(t, tokens[++i]!)
452 } else if (t.startsWith('-') && !/^-\d+$/.test(t)) flags.push(t)
453 else positional.push(t)
454 }
455 let pkg: string | null = null
456 let id: string | null = null
457 for (const p of positional) {
458 if (/^-?\d+$/.test(p) && id === null) id = p
459 else if (PACKAGE.test(p) && pkg === null) pkg = p
460 else isUnknown = true
461 }
462 if (isUnknown) continue
463
464 const segmentNotes: string[] = []
465 if (positional.length === 2 && positional[0] === id) segmentNotes.push('put the package before the job id')
466 if (!isForced && !isSatisfied) {
467 isForced = true
468 segmentNotes.push('added -f so the job runs even with unmet constraints')
469 }
470 if (pkg === null) {
471 if (ctx.pkg === null) {
472 return {
473 deny:
474 'android-device-console: `cmd jobscheduler run` needs the package before the job id ' +
475 '(`cmd jobscheduler run -f <package> <job-id>`), and no application id is known. ' +
476 'Add the package, or call mcp__android-device-console__run_worker.',
477 }
478 }
479 pkg = ctx.pkg
480 segmentNotes.push(`added the package ${pkg}`)
481 }
482 const known = ctx.jobs.filter(job => job.component.startsWith(`${pkg}/`))
483 let picked: Job | undefined
484 if (id === null) {
485 if (known.length === 1) {
486 picked = known[0]!
487 id = String(picked.id)
488 segmentNotes.push(`added job id ${id}, the only job scheduled for ${pkg}`)
489 } else {
490 const list = known.map(jobLabel).join(', ')
491 return {
492 deny:
493 `android-device-console: \`cmd jobscheduler run\` needs a job id after ${pkg}. ` +
494 (known.length > 1
495 ? `Scheduled jobs for ${pkg}: ${list}. Pick one.`
496 : `No scheduled job for ${pkg} is known; list them with \`adb shell dumpsys jobscheduler ${pkg}\`.`) +
497 ' Or call mcp__android-device-console__run_worker.',
498 }
499 }
500 }
501 if (!hasNamespace) {
502 const sameId = known.filter(job => String(job.id) === id)
503 const ns = picked?.namespace ?? (sameId.length === 1 ? sameId[0]!.namespace : null)
504 if (ns) {
505 flags.push('-n', ns)
506 segmentNotes.push(`added -n ${ns}, the JobScheduler namespace job ${id} is scheduled in`)
507 }
508 }
509 if (segmentNotes.length === 0) continue
510
511 const rebuilt = ['cmd jobscheduler run', isForced ? '-f' : '', isSatisfied ? '-s' : '', ...flags, pkg, id]
512 .filter(Boolean)
513 .join(' ')
514 out += command.slice(from, start) + rebuilt + trailing
515 from = afterRun + tail.length
516 notes.push(...segmentNotes)
517 }
518 let fixed = out + command.slice(from)
519
520 if (ctx.serialToInsert !== null && notes.length > 0 && !/\badb\s+-[sdet]\b/.test(fixed)) {
521 const before = fixed
522 fixed = fixed.replace(/\badb(\s+)shell\b/g, `adb -s ${ctx.serialToInsert}$1shell`)
523 if (fixed !== before) notes.push(`targeted ${ctx.serialToInsert} with -s`)
524 }
525 return { command: fixed, notes }
526}
527
528// --- the system-prompt section --------------------------------------------
529
530function onOff(value: boolean | null, unknown = 'unknown'): string {
531 return value === null ? unknown : value ? 'on' : 'off'
532}
533
534export function describeDevice(state: DeviceState, pkg: string | null): string {
535 const name = [state.serial, state.model, state.api !== null ? `API ${state.api}` : null]
536 .filter(Boolean)
537 .join(', ')
538 const density =
539 state.density === null
540 ? 'unknown'
541 : state.physicalDensity !== null && state.density !== state.physicalDensity
542 ? `${state.density} dpi (overridden; physical ${state.physicalDensity})`
543 : `${state.density} dpi (default)`
544 const ui = [
545 `dark mode ${onOff(state.isDark, 'auto/unknown')}`,
546 `font scale ${state.fontScale ?? 'unknown'}${state.fontScale !== null && state.fontScale !== 1 ? ' (not default)' : ''}`,
547 `density ${density}`,
548 `forced RTL ${onOff(state.isRtl)}${state.appLocales ? ` (app locales ${state.appLocales})` : ''}`,
549 `layout bounds ${onOff(state.hasLayoutBounds)}`,
550 `animations ${onOff(state.hasAnimations)}`,
551 ].join(', ')
552 const bg = [
553 state.deepIdle !== null ? `Doze deep ${state.deepIdle}` : null,
554 state.lightIdle !== null ? `light ${state.lightIdle}` : null,
555 pkg !== null && state.bucket !== null ? `${pkg} standby bucket ${state.bucket}` : null,
556 ]
557 .filter(Boolean)
558 .join(', ')
559 return [
560 `# Attached Android device (android-device-console)`,
561 `Target: ${name}.`,
562 `UI state now: ${ui}.`,
563 bg ? `Background state: ${bg}.` : '',
564 'Account for these when you judge a screenshot or UI behaviour on this device. ' +
565 'Change them with mcp__android-device-console__set_device_state and set_doze; ' +
566 'force-run a WorkManager job with run_worker.',
567 ]
568 .filter(Boolean)
569 .join('\n')
570}
571types/index.d.ts 52 lines1export type ExecMode = 'process' | 'bash'
2
3export type Device = { serial: string; state: string; model: string | null }
4
5export type DeviceState = {
6 serial: string
7 model: string | null
8 api: number | null
9 isDark: boolean | null
10 fontScale: number | null
11 density: number | null
12 physicalDensity: number | null
13 isRtl: boolean | null
14 /** The app's own (per-app) locales, `''` for none; null where unreadable. */
15 appLocales: string | null
16 hasLayoutBounds: boolean | null
17 hasAnimations: boolean | null
18 deepIdle: string | null
19 lightIdle: string | null
20 bucket: string | null
21 readAt: number
22}
23
24export type Job = {
25 id: number
26 /** The JobScheduler namespace (API 34+), such as WorkManager's; null for the default. */
27 namespace: string | null
28 component: string
29 workSpecId: string | null
30 required: string | null
31 unsatisfied: string | null
32}
33
34declare module 'claude-code' {
35 interface PluginState {
36 'android-device-console': {
37 mode: ExecMode | null
38 adb: string
39 probeNote: string | null
40 devices: Device[]
41 chosenSerial: string | null
42 device: DeviceState | null
43 jobs: Job[]
44 pkg: string | null
45 busy: string | null
46 lastMessage: string | null
47 /** The app's locales before forced RTL replaced them, put back when RTL goes off. */
48 rtlRestore: string | null
49 }
50 }
51}
52