Bounds the model's adb logcat calls, redacts secrets from adb output, keeps a pid-scoped logcat buffer with a grep tool and a /logcat pane.

Four Claude Code mods for Android and Kotlin Multiplatform work. A mod is a plugin of function hooks: it changes what Claude Code shows and does, by drawing a band or pane, adding tools the model can call, or rewriting the model's tool calls before they run.
| Mod | What it gives you |
|---|---|
| build-output-distiller | Gradle output becomes a short digest: Kotlin, KSP, Hilt/Dagger, Room, AAPT, R8 and test failures as file:line lists with fix hints. Repeat failures are flagged. /build opens the errors in a pane. |
| android-logcat | Bare adb logcat calls get a bound (-d -t 800, or -t 200 --pid=<pid>) so they can't hang. Secrets are redacted from adb output. A pid-scoped buffer feeds a logcat_grep tool and a /logcat pane. |
| android-device-console | A band of live toggles above the prompt: dark mode, font scale, density, RTL, layout bounds, animations. /device adds Doze, standby bucket and scheduled jobs with Run buttons. Tools: set_device_state, set_doze, run_worker. The system prompt states the current device state. |
| android-project-lens | A nav_graph tool that indexes @Serializable routes and where nav hosts register them. /stability gives Compose stability reports on demand. The system prompt gets the real targetSdk, minSdk and permissions. Claude asks before adding Play-declaration permissions. |
The mods don't overlap: only build-output-distiller hooks Gradle commands, and only android-logcat hooks adb logcat.
claude --version.adb on your PATH, or ANDROID_HOME / ANDROID_SDK_ROOT set, for the three mods that talk to a device.Install from a terminal session. Each mod installs on its own, so install only the ones you want.
At the Claude Code prompt:
/plugin install android-device-console --marketplace NathanWA/claude-android-mods
/plugin install android-logcat --marketplace NathanWA/claude-android-mods
/plugin install build-output-distiller --marketplace NathanWA/claude-android-mods
/plugin install android-project-lens --marketplace NathanWA/claude-android-mods
The first install asks Add marketplace?: answer y. Then choose a scope; the user scope is first and is the usual choice. The mod is active in that session right away and in every session you start after.
From a shell:
claude plugin install android-device-console --marketplace NathanWA/claude-android-mods
In the Claude desktop app, /plugin doesn't run in the Code tab. Click + next to the prompt box, then Plugins, then Add marketplace with NathanWA/claude-android-mods, then Add plugin. A mod you install from a terminal at user scope also loads in the desktop app's local sessions.
To update later:
claude plugin update android-device-console
There's nothing to start. Open Claude Code in your Android project and the mods work on their own:
./gradlew assembleDebug, testDebugUnitTest). The model reads a digest instead of the raw log. Run /build to see the last build's errors in a pane.adb logcat, the call is bounded and redacted. The model can search the buffer with logcat_grep. Run /logcat to see the buffer. The buffer follows the app when it restarts and gets a new pid, and logcat_grep warns if that pid looks stale./device to open the console. After the first read, the band of toggles stays above the prompt; press 1–6 once the band has focus. Ask Claude things like "screenshot the settings screen in dark mode at font scale 1.3": it calls set_device_state itself. /device reset restores the defaults.nav_graph. Run /stability to compile once with Compose compiler reports, or /stability read to read existing reports.Several mods run adb through Claude Code's $.process, which is documented as CLI-only. Where it isn't available, as in the desktop app, they fall back to the Bash tool, and each call asks for permission. To stop the prompts, add this to permissions.allow in ~/.claude/settings.json:
"Bash(adb *)"
The mods never poll through the Bash tool. In the desktop app, device state is re-read only when you run a command, press a button, or the model calls a tool.
android-device-console picks its target in this order: ANDROID_SERIAL, then the device you picked in /device, then the only connected device. When several devices are connected and none is chosen, it asks rather than guesses.
android-device-console has two options, which you can set in /config or under pluginConfigs in settings:
package: the applicationId. When empty, it is read from app/build.gradle(.kts).pollSeconds: how often device state is refreshed when $.process works. The default is 15; 0 turns refreshing off.These mods are early. Each passes claude plugin validate and its own claude plugin test suite, but they have had little use on real projects and devices. Known gaps:
dumpsys output differs between API levels. The parsers read it defensively, but untested API levels may show ?.debug.force_rtl, which may need a reboot. Layout bounds use a service call that some system images ignore./stability Gradle init script has not yet been run against every AGP and Kotlin version.Issues and pull requests are welcome.
Clone the repository and load a mod from disk for one session:
claude --plugin-dir plugins/android-device-console
Check and test a mod:
claude plugin validate plugins/android-device-console
claude plugin test plugins/android-device-console
Each mod's tsconfig.json extends the types Claude Code writes into .claude-plugin/types/ when it loads the mod. After one load, tsc -p plugins/<mod> type-checks it.
hooks/register.tsx 638 lines1import { atom, read, update } from 'claude-code'
2import type { ApiContentBlock, Register, ThemeKey } from 'claude-code'
3
4import type { LogLevel, LogLine, LogcatFilter, LogcatView } from '../types'
5import {
6 AppPids,
7 LEVELS,
8 appEvent,
9 applicationIdIn,
10 applyFilter,
11 boundLogcat,
12 countOf,
13 formatLine,
14 grep,
15 isLogcatCommand,
16 lineKey,
17 packageIn,
18 pidInPidof,
19 pidInPs,
20 parseLines,
21 redact,
22 restartsApp,
23 stampMs,
24 summarize,
25} from './logcat'
26
27const PLUGIN = 'android-logcat'
28const PANE = 'android-logcat'
29const GREP_TOOL = 'logcat_grep'
30const RING = 2000
31const VISIBLE = 80
32
33const EMPTY_VIEW: LogcatView = {
34 lines: [],
35 total: 0,
36 matched: 0,
37 earlierPids: [],
38 isPidStale: false,
39 source: 'observe',
40 redactions: 0,
41}
42/** How long a pid learned from the model's own adb output is trusted for `--pid`. */
43const FRESH_MS = 30_000
44
45const view = atom({ plugin: 'android-logcat', key: 'view' } as const, EMPTY_VIEW)
46const filter = atom({ plugin: 'android-logcat', key: 'filter' } as const, { level: 'V', text: '' } as LogcatFilter)
47const adbCalls = atom({ plugin: 'android-logcat', key: 'adbCalls' } as const, [] as string[])
48
49// The ring lives here, not in $.state: only the visible window is mirrored.
50// A reload empties it, as it also ends the live child. It keeps every pid's
51// lines: which are the app's is decided when reading, by the pid history, so a
52// restart never strands the new process's lines.
53const ring: LogLine[] = []
54const ringKeys = new Set<string>()
55const app = new AppPids()
56let pkg: string | undefined
57let redactions = 0
58let currentFilter: LogcatFilter = { level: 'V', text: '' }
59let canSpawn = false
60let mirrorPending = false
61
62// The one owner of the live logcat child.
63let liveGeneration = 0
64let live: { pid: number; stop: () => void } | undefined
65let liveCarry = ''
66// Set by session.start where $.process works: (re)start the live child, and
67// ask the device for the app's pid now.
68let startLive: ((target: number) => void) | undefined
69let verifyPid: (() => Promise<void>) | undefined
70
71const PRIMARY_GROUPS = [
72 'app/build.gradle.kts',
73 'app/build.gradle',
74 'androidApp/build.gradle.kts',
75 'composeApp/build.gradle.kts',
76]
77
78const lastTimeOf = (target: number | undefined) => {
79 if (target === undefined) return undefined
80 for (let i = ring.length - 1; i >= 0; i--) if (ring[i]?.pid === target) return ring[i]?.time
81 return undefined
82}
83
84function setPackage(name: string) {
85 if (name === pkg) return
86 pkg = name
87 app.reset()
88}
89
90/** The app's pid is `next` now; answers whether it changed. */
91const adoptPid = (next: number) => app.adopt(next, Date.now(), lastTimeOf(app.current))
92
93/**
94 * Adds lines the ring has not seen, oldest first, and follows ActivityManager's
95 * word on the app's process starting and dying. Answers whether the pid changed.
96 */
97function ingest(lines: readonly LogLine[]): boolean {
98 const before = app.current
99 for (const line of lines) {
100 const key = lineKey(line)
101 if (ringKeys.has(key)) continue
102 ringKeys.add(key)
103 ring.push(line)
104 const event = pkg === undefined ? undefined : appEvent(line, pkg)
105 if (event?.kind === 'start') {
106 // A start older than what the current pid has logged is history, not news.
107 const latest = lastTimeOf(app.current)
108 if (latest === undefined || line.time === '' || line.time >= latest) adoptPid(event.pid)
109 } else if (event?.kind === 'death' && event.pid === app.current) {
110 app.retire(line.time)
111 }
112 }
113 if (ring.length > RING) {
114 for (const gone of ring.splice(0, ring.length - RING)) ringKeys.delete(lineKey(gone))
115 }
116
117 return app.current !== before
118}
119
120const appLines = () => ring.filter(l => app.owns(l))
121
122/** The app restarted and its new pid is not known yet. */
123const isPidStale = () => app.known.length > 0 && (app.current === undefined || app.verifiedAt === 0)
124
125/** The pid to scope a logcat call to: only one confirmed lately. */
126const freshPid = () =>
127 app.current !== undefined && Date.now() - app.verifiedAt < FRESH_MS ? app.current : undefined
128
129/** The visible window of the app's lines, filtered: all that is mirrored to $.state. */
130function viewNow(): LogcatView {
131 const matched = applyFilter(appLines(), currentFilter)
132 return {
133 lines: matched.slice(-VISIBLE),
134 total: ring.length,
135 matched: matched.length,
136 earlierPids: app.earlier,
137 isPidStale: isPidStale(),
138 source: live ? 'spawn' : 'observe',
139 redactions,
140 ...(pkg !== undefined && { pkg }),
141 ...(app.current !== undefined && { pid: app.current }),
142 }
143}
144
145const parsePid = (ran: { exitCode: number | null; stdout: string } | undefined) =>
146 ran?.exitCode === 0 ? pidInPidof(ran.stdout) : undefined
147
148function stopLive() {
149 liveGeneration++
150 live?.stop()
151 live = undefined
152}
153
154/** Feeds one piece of the live child's output; answers whether lines landed. */
155function feed(text: string): boolean {
156 const all = liveCarry + text
157 const cut = all.lastIndexOf('\n')
158 if (cut < 0) {
159 liveCarry = all
160 return false
161 }
162 liveCarry = all.slice(cut + 1)
163 const scrubbed = redact(all.slice(0, cut))
164 redactions += countOf(scrubbed.counts)
165 if (ingest(parseLines(scrubbed.text)) && app.current !== undefined) startLive?.(app.current)
166 return true
167}
168
169/** A tool_result block with its text redacted; the same block when nothing matched. */
170function redactBlock<B extends ApiContentBlock>(block: B): B {
171 if (block.type !== 'tool_result') return block
172 const content = block.content
173 if (typeof content === 'string') {
174 const scrubbed = redact(content)
175 return countOf(scrubbed.counts) === 0 ? block : { ...block, content: scrubbed.text }
176 }
177 if (!Array.isArray(content)) return block
178 let changed = false
179 const inner = content.map(part => {
180 if (part.type !== 'text') return part
181 const scrubbed = redact(part.text)
182 if (countOf(scrubbed.counts) === 0) return part
183 changed = true
184 return { ...part, text: scrubbed.text }
185 })
186 return changed ? { ...block, content: inner } : block
187}
188
189const GREP_DESCRIPTION = [
190 "Search android-logcat's buffer of the app's logcat lines (secrets already redacted). By default it searches the app's pids this session, the current one and any earlier ones from restarts; scope \"all\" searches every pid.",
191 'Prefer this over running adb logcat again. Filters combine: tag (substring), level (minimum: V D I W E F), regex (case-insensitive, over tag and message).',
192 'The buffer fills from adb logcat calls you run and, where available, a live adb logcat child.',
193].join(' ')
194
195const levelColor = (level: LogLevel): ThemeKey | undefined =>
196 level === 'E' || level === 'F' ? 'error' : level === 'W' ? 'warning' : undefined
197
198const asLevel = (value: unknown): LogLevel | undefined =>
199 typeof value === 'string' && (LEVELS as readonly string[]).includes(value.toUpperCase())
200 ? (value.toUpperCase() as LogLevel)
201 : undefined
202
203export const register: Register = on => {
204 on('session.start', async ($, e, next) => {
205 await $.tool.register({
206 name: GREP_TOOL,
207 description: GREP_DESCRIPTION,
208 isDeferred: false,
209 inputSchema: {
210 type: 'object',
211 properties: {
212 tag: { type: 'string', description: 'Tag substring, e.g. "OkHttp" or "AndroidRuntime".' },
213 level: { type: 'string', enum: [...LEVELS], description: 'Minimum level.' },
214 regex: { type: 'string', description: 'Case-insensitive regex over tag and message.' },
215 limit: { type: 'number', description: 'Most recent matches to return (default 100, max 500).' },
216 scope: {
217 type: 'string',
218 enum: ['app', 'all'],
219 description: 'app (default): the app\'s pids this session. all: every pid in the buffer.',
220 },
221 },
222 },
223 })
224 await $.command.register({
225 name: 'logcat',
226 description: "Show android-logcat's pid-scoped logcat buffer in a pane",
227 })
228 const started = await next(e)
229
230 // The one owner of the live child: this hook's closure. A reload kills the
231 // child with the module, and session.start fires again to start a new one.
232 const mirrorSoon = () => {
233 if (mirrorPending) return
234 mirrorPending = true
235 $.clock.after(300, () => {
236 mirrorPending = false
237 void update($, view, viewNow).catch(() => undefined)
238 })
239 }
240 const launch = (target: number) => {
241 if (live?.pid === target) return
242 stopLive()
243 const generation = liveGeneration
244 liveCarry = ''
245 const stream = $.process.spawn({
246 argv: ['adb', 'logcat', '-v', 'threadtime', '-T', '1', `--pid=${target}`],
247 })
248 live = { pid: target, stop: () => void stream.return(undefined as never).catch(() => undefined) }
249 void (async () => {
250 try {
251 for await (const piece of stream) {
252 if (generation !== liveGeneration) break
253 if (feed(piece.text)) mirrorSoon()
254 }
255 } catch {
256 // adb went away or the device dropped: observe the model's calls instead.
257 } finally {
258 if (generation === liveGeneration) {
259 live = undefined
260 mirrorSoon()
261 }
262 }
263 })()
264 }
265
266 void (async () => {
267 currentFilter = await read($, filter)
268 if (pkg === undefined) {
269 const root = await $.session.root().catch(() => undefined)
270 for (const file of root === undefined ? [] : PRIMARY_GROUPS) {
271 const text = await $.fs.read(`${root}/${file}`).catch(() => undefined)
272 const id = typeof text === 'string' ? applicationIdIn(text) : undefined
273 if (id !== undefined) {
274 pkg = id
275 break
276 }
277 }
278 }
279 // $.process is documented CLI-only: probe it, and degrade to observing.
280 const probe = await $.process.run(['adb', 'version'], { timeoutMs: 5000 }).catch(() => undefined)
281 canSpawn = probe?.exitCode === 0
282 if (canSpawn) {
283 startLive = launch
284 // Ask the device which pid is the app's now. A restart from anywhere
285 // (another tool, the terminal, a crash) shows up here: the child follows.
286 verifyPid = async () => {
287 const target = pkg
288 if (target === undefined) return
289 const ran = await $.process
290 .run(['adb', 'shell', 'pidof', '-s', target], { timeoutMs: 5000 })
291 .catch(() => undefined)
292 if (ran === undefined || target !== pkg) return
293 const found = parsePid(ran)
294 if (found !== undefined) {
295 adoptPid(found)
296 launch(found)
297 } else if (ran.exitCode === 1 && ran.stderr.trim() === '') {
298 // pidof found no such process: the app is not running.
299 app.retire(lastTimeOf(app.current))
300 stopLive()
301 }
302 mirrorSoon()
303 }
304 await verifyPid()
305 $.clock.every(5000, () => void verifyPid?.().catch(() => undefined))
306 }
307 await update($, view, viewNow)
308 })().catch(() => undefined)
309
310 return started
311 })
312
313 // Bound the model's own logcat calls, then redact what any adb call returns.
314 on('tool.call', { tool: 'Bash', command: /\badb\b/ }, async ($, e, next) => {
315 // build-output-distiller owns Gradle commands.
316 if (/gradlew/.test(e.command)) return next(e)
317
318 const named = packageIn(e.command)
319 if (named !== undefined) setPackage(named)
320 if (restartsApp(e.command)) {
321 // The app is (re)starting: its pid is no longer trusted until confirmed.
322 app.distrust()
323 if (verifyPid !== undefined) {
324 $.clock.after(2000, () => void verifyPid?.().catch(() => undefined))
325 $.clock.after(6000, () => void verifyPid?.().catch(() => undefined))
326 }
327 }
328
329 const isLogcat = isLogcatCommand(e.command)
330 let command = e.command
331 let added: string[] = []
332 if (isLogcat && e.run_in_background !== true) {
333 await verifyPid?.().catch(() => undefined)
334 // Only a pid confirmed lately scopes the call; else the call is left
335 // unscoped, which also brings in ActivityManager's word on restarts.
336 const target = freshPid()
337 const bounded = boundLogcat(e.command, target === undefined ? {} : { pid: target })
338 command = bounded.command
339 added = bounded.added
340 }
341
342 await update($, adbCalls, ids => [...ids, e.tool_use_id].slice(-200))
343 const ran = await next(command === e.command ? e : { ...e, command })
344 if (ran.deny !== undefined) return ran
345
346 const asksPidof = pkg !== undefined && new RegExp(`\\bpidof\\s+(?:-s\\s+)?${pkg.replace(/\./g, '\\.')}\\b`).test(e.command)
347
348 if (ran.isError === true) {
349 // `pidof <app>` exits 1 with no output when the app is not running.
350 if (asksPidof && (ran.text ?? '').trim().replace(/^Exit code 1$/, '') === '') {
351 app.retire(lastTimeOf(app.current))
352 await update($, view, viewNow)
353 }
354 const scrubbed = redact(ran.text ?? '')
355 return countOf(scrubbed.counts) === 0 ? ran : { deny: scrubbed.text }
356 }
357
358 const record = ran.result
359 // The model asked the device for the app's pid: take its answer.
360 const told =
361 (asksPidof ? pidInPidof(record.stdout) : undefined) ??
362 (pkg !== undefined && /\bps\b/.test(e.command) ? pidInPs(record.stdout, pkg) : undefined)
363 if (told !== undefined) {
364 adoptPid(told)
365 startLive?.(told)
366 }
367
368 const out = redact(record.stdout)
369 const err = redact(record.stderr)
370 const counts: Record<string, number> = { ...out.counts }
371 for (const [kind, n] of Object.entries(err.counts)) counts[kind] = (counts[kind] ?? 0) + n
372
373 // A large output is also on disk, where the model can Read it: scrub that too.
374 if (record.persistedOutputPath !== undefined) {
375 const path = record.persistedOutputPath
376 const ok = await $.fs
377 .read(path)
378 .then(async raw => {
379 const disk = redact(raw)
380 for (const [kind, n] of Object.entries(disk.counts)) counts[kind] = Math.max(counts[kind] ?? 0, n)
381 if (countOf(disk.counts) > 0) await $.fs.write(path, disk.text)
382 return true
383 })
384 .catch(() => false)
385 if (!ok) {
386 return {
387 deny: `${PLUGIN}: this adb output was too large to redact, so it was withheld. Re-run with a filter (grep, -t N, --pid).`,
388 }
389 }
390 }
391
392 const found = countOf(counts)
393 redactions += found
394 if (isLogcat || told !== undefined) {
395 if (ingest(parseLines(out.text)) && app.current !== undefined) startLive?.(app.current)
396 await update($, view, viewNow)
397 }
398 if (found > 0) $.ui.toast(`${PLUGIN}: redacted ${summarize(counts)} from adb output`)
399
400 if (found === 0 && added.length === 0) return ran
401
402 const notes: string[] = []
403 if (added.length > 0) {
404 notes.push(
405 `${PLUGIN} added \`${added.join('`, `')}\` to this logcat call so it could not stream forever. Leave it bounded; search what was captured with mcp__${PLUGIN}__${GREP_TOOL}.`,
406 )
407 }
408 if (found > 0) {
409 notes.push(
410 `${PLUGIN} redacted ${summarize(counts)} from this adb output. A placeholder such as <EMAIL#1> stands for the same value wherever it appears.`,
411 )
412 }
413
414 // A fresh result: no `ref` and no `text`, so core builds the model's copy
415 // and the transcript record from the redacted output.
416 return {
417 result: { ...record, stdout: out.text, stderr: err.text },
418 context: [...(ran.context ?? []), ...notes],
419 }
420 }).catch(($, e, next) =>
421 next.called
422 ? { deny: `${PLUGIN}: redaction failed, so the adb output was withheld.` }
423 : next(e),
424 )
425
426 // Safety net: a tool result from a recorded adb call is scrubbed again as it
427 // is stored. Redaction is idempotent, so a result already scrubbed passes.
428 on('session.append', { door: 'tool-result' }, async ($, e, next) => {
429 const ids = await read($, adbCalls)
430 const content = e.message.content.map(block =>
431 block.type === 'tool_result' && ids.includes(String(block.tool_use_id)) ? redactBlock(block) : block,
432 )
433 const changed = content.some((block, i) => block !== e.message.content[i])
434
435 return changed ? next({ ...e, message: { ...e.message, content } }) : next(e)
436 }).catch(($, e, next) =>
437 // Failing closed: with the adb ids unknown, every tool result is scrubbed.
438 next({ ...e, message: { ...e.message, content: e.message.content.map(redactBlock) } }),
439 )
440
441 on('tool.call', { tool: 'mcp__android-logcat__logcat_grep' }, async ($, e) => {
442 const regex = typeof e.regex === 'string' && e.regex !== '' ? e.regex : undefined
443 if (regex !== undefined) {
444 try {
445 new RegExp(regex, 'i')
446 } catch (error) {
447 return { result: `Invalid regex ${JSON.stringify(regex)}: ${String(error)}` }
448 }
449 }
450 if (ring.length === 0) {
451 return {
452 result:
453 'The logcat buffer is empty. Run `adb logcat -d -t 800` (android-logcat bounds a bare `adb logcat` for you) to fill it, then search again.',
454 }
455 }
456 await verifyPid?.().catch(() => undefined)
457 const level = asLevel(e.level)
458 const tag = typeof e.tag === 'string' && e.tag !== '' ? e.tag : undefined
459 const limit = Math.min(500, Math.max(1, typeof e.limit === 'number' ? Math.floor(e.limit) : 100))
460 const query = {
461 ...(tag !== undefined && { tag }),
462 ...(level !== undefined && { level }),
463 ...(regex !== undefined && { regex }),
464 }
465 const everything = e.scope === 'all' || app.known.length === 0
466 const pool = everything ? ring : appLines()
467 const hits = grep(pool, query)
468 const others = everything ? [] : grep(ring.filter(l => !app.owns(l)), query)
469 const shown = hits.slice(-limit)
470
471 const appPids = [
472 app.current !== undefined ? `pid ${app.current}` : 'current pid unknown',
473 ...(app.earlier.length > 0 ? [`earlier ${app.earlier.join(', ')}`] : []),
474 ].join(', ')
475 const scope = everything
476 ? app.known.length === 0
477 ? 'all pids: the app pid is not known yet'
478 : 'all pids'
479 : `${pkg ?? 'app'}: ${appPids}`
480 const head = `${hits.length} of ${pool.length} ${everything ? 'buffered' : 'app'} lines match (${scope})${
481 hits.length > shown.length ? `; the last ${shown.length} shown` : ''
482 }.`
483
484 // Never let a stale pid read as "the app logged nothing".
485 const notes: string[] = []
486 if (!everything) {
487 const lastApp = stampMs(lastTimeOf(app.current) ?? '')
488 const lastAny = stampMs(ring[ring.length - 1]?.time ?? '')
489 if (isPidStale()) {
490 notes.push(
491 `Warning: the app restarted since its pid was last confirmed, and the new pid is not known yet, so the new process's lines are not counted as the app's.`,
492 )
493 } else if (lastApp !== undefined && lastAny !== undefined && lastAny - lastApp > 60_000 && others.length > 0) {
494 notes.push(
495 `Warning: pid ${app.current} last logged at ${lastTimeOf(app.current)}, while the device logged until ${
496 ring[ring.length - 1]?.time
497 }. If the app restarted, that pid is stale.`,
498 )
499 }
500 if (others.length > 0 && (hits.length === 0 || notes.length > 0)) {
501 const pids = [...new Set(others.map(l => l.pid))].slice(0, 6)
502 notes.push(
503 `${others.length} lines from other pids match (pid ${pids.join(', ')}). If the app restarted, run \`adb shell pidof -s ${
504 pkg ?? '<package>'
505 }\` and android-logcat rescopes to the new pid, or search again with scope "all".`,
506 )
507 }
508 }
509
510 return { result: [head, ...notes, ...shown.map(formatLine)].join('\n') }
511 }).catch(() => ({ deny: `${PLUGIN}: the logcat search failed.` }))
512
513 on('command.run', { command: 'logcat' }, async $ => {
514 await update($, view, viewNow)
515 const placed = await $.ui.open({
516 id: PANE,
517 title: `logcat · ${pkg ?? 'all apps'}`,
518 focus: true,
519 closeOnEscape: true,
520 })
521
522 return {
523 text: placed.isPlaced
524 ? `Opened the logcat pane (${ring.length} buffered lines).`
525 : `The logcat pane is open but no surface draws it here; ${ring.length} lines are buffered.`,
526 }
527 })
528
529 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
530 const { Box, Text, Button } = $.ui.resolve(e)
531 const v = await read($, view)
532 const f = await read($, filter)
533 const room = Math.max(3, (e.viewport?.rows ?? 30) - 7)
534
535 const setFilter = async (change: Partial<LogcatFilter>) => {
536 currentFilter = { ...currentFilter, ...change }
537 await update($, filter, () => currentFilter)
538 await update($, view, viewNow)
539 }
540 const status = [
541 v.pkg ?? 'app package unknown',
542 v.pid !== undefined
543 ? `pid ${v.pid}${v.earlierPids.length > 0 ? ` (earlier ${v.earlierPids.join(', ')})` : ''}`
544 : v.earlierPids.length > 0
545 ? `app not running (earlier ${v.earlierPids.join(', ')})`
546 : 'all pids',
547 ...(v.isPidStale ? ['restarted, new pid not known yet'] : []),
548 v.source === 'spawn' ? 'live' : 'from adb calls',
549 `${v.matched}/${v.total} lines`,
550 `${v.redactions} redacted`,
551 ].join(' · ')
552
553 const controls =
554 e.surface === 'mobile' ? null : (() => {
555 const { Input, Select } = $.ui.resolve(e)
556 return (
557 <Box flexDirection="row" gap={1}>
558 <Select
559 key="level"
560 label="Level"
561 value={f.level}
562 options={LEVELS.map(l => ({ value: l, label: l }))}
563 onSelect={value => void setFilter({ level: asLevel(value) ?? 'V' })}
564 />
565 <Input
566 key="filter"
567 label="Filter"
568 placeholder="tag or text"
569 value={f.text}
570 submitLabel="filter"
571 onInput={value => void setFilter({ text: value })}
572 onSubmit={value => void setFilter({ text: value })}
573 />
574 </Box>
575 )
576 })()
577
578 return (
579 <Box flexDirection="column">
580 <Text dimColor>{status}</Text>
581 {controls}
582 <Box flexDirection="row" gap={1}>
583 <Button
584 key="clear"
585 hotkey="c"
586 label="Clear"
587 onPress={() => {
588 ring.length = 0
589 void update($, view, viewNow)
590 }}
591 />
592 <Button
593 key="send"
594 hotkey="k"
595 label="Send to Claude"
596 variant="primary"
597 onPress={async () => {
598 const shown = (await read($, view)).lines
599 if (shown.length === 0) return void $.ui.toast(`${PLUGIN}: nothing to send`)
600 await $.session.append({
601 message: {
602 type: 'user',
603 content: [
604 {
605 type: 'text',
606 text: `Logcat lines the person sent from the android-logcat pane (${status}):\n${shown
607 .map(formatLine)
608 .join('\n')}`,
609 },
610 ],
611 },
612 })
613 $.ui.toast(`${PLUGIN}: sent ${shown.length} lines; they ride with your next prompt`)
614 }}
615 />
616 </Box>
617 {v.lines.length === 0 && (
618 <Text dimColor>
619 No lines yet. They arrive from adb logcat calls{v.source === 'spawn' ? ' and the live child' : ''}.
620 </Text>
621 )}
622 {v.lines.slice(-room).map((l, i) => {
623 const color = levelColor(l.level)
624 return (
625 <Text
626 key={`l${i}`}
627 wrap="truncate-end"
628 {...(color !== undefined ? { color } : { dimColor: l.level === 'V' || l.level === 'D' })}
629 >
630 {l.time.slice(6)} {l.level} {l.tag}: {l.msg}
631 </Text>
632 )
633 })}
634 </Box>
635 )
636 })
637}
638hooks/logcat.ts 328 lines1// Pure helpers: bounding logcat commands, redacting adb output, parsing and
2// searching logcat lines. No `$` here, so tests can import them directly.
3
4import type { LogLevel, LogLine, LogcatFilter } from '../types'
5
6export const LEVELS: readonly LogLevel[] = ['V', 'D', 'I', 'W', 'E', 'F']
7
8export const levelRank = (level: LogLevel) => LEVELS.indexOf(level)
9
10// --- Bounding -------------------------------------------------------------
11
12/** `adb [global opts] logcat [args]`, args stopping at a shell separator. */
13const LOGCAT_CALL =
14 /\badb\b((?:\s+(?:-[adeHU]|-[sStPL]\s+\S+|--\S+))*)\s+logcat\b([^|;&\n)]*)/g
15
16/** Flags that already make logcat return (or not stream) on their own. */
17const BOUNDED_FLAG =
18 /^(?:-d|-t\S*|--pid(?:=\S*)?|-m\S*|--max-count(?:=\S*)?|-c|--clear|-g|--get-buffer-size|-G\S*|--buffer-size(?:=\S*)?|-S|--statistics|-p|--prune|-P\S*|-L|--last|-h|--help)$/
19
20export type Bounding = {
21 command: string
22 /** What was added, once per rewritten call; empty when nothing changed. */
23 added: string[]
24}
25
26export type BoundTarget = { pid?: number }
27
28/**
29 * Whether text ending at `before` leaves the shell expecting a command: the
30 * start, a separator, a subshell or substitution, a keyword, after any env
31 * assignments and wrappers. Text inside quotes or an argument is not one.
32 */
33function atCommandPosition(before: string): boolean {
34 let rest = before.replace(/\s+$/, '')
35 for (;;) {
36 const wrapper = /(?:^|\s)(?:[A-Za-z_]\w*=\S*|sudo|exec|command|nohup|time|env)$/.exec(rest)
37 if (!wrapper) break
38 rest = rest.slice(0, wrapper.index).replace(/\s+$/, '')
39 }
40
41 return rest === '' || /(?:[;&|({\n`]|\$\(|\b(?:then|do|else|elif))$/.test(rest)
42}
43
44/**
45 * Adds `-d -t 800` (or `-t 200 --pid=<pid>` when the app's pid is known) to
46 * every logcat call in `command` that would otherwise stream forever. Only
47 * calls the shell would run are touched: none in quotes, comments or a heredoc.
48 */
49export function boundLogcat(command: string, target: BoundTarget = {}): Bounding {
50 const added: string[] = []
51 const heredoc = command.indexOf('<<')
52 const out = command.replace(LOGCAT_CALL, (whole, _globals: string, args: string, offset: number) => {
53 if (heredoc >= 0 && offset > heredoc) return whole
54 const tokens = args.trim().split(/\s+/).filter(Boolean)
55 if (tokens.some(token => BOUNDED_FLAG.test(token))) return whole
56 const after = command.slice(offset + whole.length)
57 if (/^\s*\|\s*head\b/.test(after)) return whole
58 const before = command.slice(0, offset)
59 if (/\btimeout\s+\S+\s*$/.test(before)) return whole
60 if (!atCommandPosition(before)) return whole
61 const flags = target.pid !== undefined ? `-t 200 --pid=${target.pid}` : '-d -t 800'
62 added.push(flags)
63 const at = whole.length - args.length
64 return `${whole.slice(0, at)} ${flags}${args}`
65 })
66
67 return { command: out, added }
68}
69
70export const isLogcatCommand = (command: string) => /\badb\b[^|;&\n]*\blogcat\b/.test(command)
71
72// --- Redaction --------------------------------------------------------------
73
74type Rule = { kind: string; pattern: RegExp; keep?: number }
75
76// Each pattern is linear: no nested quantifiers. `keep` is how many leading
77// capture groups stay as written (a header name, a key and its `=`).
78const RULES: readonly Rule[] = [
79 {
80 kind: 'SECRET',
81 pattern:
82 /\b((?:Proxy-)?Authorization|Cookie|Set-Cookie|X-Api-Key|X-Auth-Token|X-Goog-Api-Key)(\s*:\s*)([^\r\n]+)/gi,
83 keep: 2,
84 },
85 { kind: 'TOKEN', pattern: /\b(Bearer\s+)([A-Za-z0-9\-._~+/]{8,}=*)/g, keep: 1 },
86 { kind: 'JWT', pattern: /\beyJ[A-Za-z0-9_-]{5,}\.[A-Za-z0-9_-]{5,}\.[A-Za-z0-9_-]{5,}/g },
87 { kind: 'FCM', pattern: /\b[A-Za-z0-9_-]{11,}:APA91[A-Za-z0-9_-]{60,}/g },
88 { kind: 'KEY', pattern: /\bAIza[0-9A-Za-z_-]{35}\b/g },
89 {
90 kind: 'SECRET',
91 pattern:
92 /\b([A-Za-z0-9_-]{0,40}(?:token|secret|passw(?:or)?d|api[_-]?key|session[_-]?id))(["']?\s*[:=]\s*["']?)([^\s"'&,;)}\]<>]{4,})/gi,
93 keep: 2,
94 },
95 { kind: 'EMAIL', pattern: /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)*\.[A-Za-z]{2,}\b/g },
96 { kind: 'PHONE', pattern: /(?<![\w+])\+[1-9]\d{7,14}\b/g },
97]
98
99export type Redaction = { text: string; counts: Record<string, number> }
100
101/**
102 * Replaces secrets with stable placeholders (`<EMAIL#1>`): the same value
103 * gets the same placeholder within one text, so the model can still follow
104 * it across lines. Placeholders never match a rule, so scrubbing twice is safe.
105 */
106export function redact(text: string): Redaction {
107 const counts: Record<string, number> = {}
108 const seen = new Map<string, string>()
109 const placeholder = (kind: string, value: string) => {
110 const known = seen.get(`${kind}\0${value}`)
111 if (known !== undefined) return known
112 const n = (counts[kind] = (counts[kind] ?? 0) + 1)
113 const made = `<${kind}#${n}>`
114 seen.set(`${kind}\0${value}`, made)
115 return made
116 }
117 let out = text
118 for (const rule of RULES) {
119 out = out.replace(rule.pattern, (whole: string, ...groups: unknown[]) => {
120 const keep = rule.keep ?? 0
121 if (keep === 0) return placeholder(rule.kind, whole)
122 const parts = groups.slice(0, keep + 1).map(g => (typeof g === 'string' ? g : ''))
123 const value = parts[keep] ?? ''
124 if (value.startsWith('<')) return whole
125 return parts.slice(0, keep).join('') + placeholder(rule.kind, value)
126 })
127 }
128
129 return { text: out, counts }
130}
131
132export const countOf = (counts: Record<string, number>) =>
133 Object.values(counts).reduce((a, b) => a + b, 0)
134
135export const summarize = (counts: Record<string, number>) =>
136 Object.entries(counts)
137 .map(([kind, n]) => `${n} ${kind.toLowerCase()}`)
138 .join(', ')
139
140// --- Parsing ----------------------------------------------------------------
141
142const THREADTIME =
143 /^(\d\d-\d\d\s+\d\d:\d\d:\d\d\.\d+)\s+(\d+)\s+(\d+)\s+([VDIWEFA])\s+(.*?)\s*: (.*)$/
144const BRIEF = /^([VDIWEFA])\/(.+?)\(\s*(\d+)\):\s?(.*)$/
145
146const asLevel = (l: string): LogLevel => (l === 'A' ? 'F' : (l as LogLevel))
147
148export function parseLine(raw: string): LogLine | undefined {
149 const line = raw.replace(/\r$/, '')
150 const t = THREADTIME.exec(line)
151 if (t) {
152 return {
153 time: t[1] ?? '',
154 pid: Number(t[2]),
155 tid: Number(t[3]),
156 level: asLevel(t[4] ?? 'I'),
157 tag: t[5] ?? '',
158 msg: t[6] ?? '',
159 }
160 }
161 const b = BRIEF.exec(line)
162 if (b) {
163 const pid = Number(b[3])
164 return { time: '', pid, tid: pid, level: asLevel(b[1] ?? 'I'), tag: b[2] ?? '', msg: b[4] ?? '' }
165 }
166
167 return undefined
168}
169
170export const parseLines = (text: string) =>
171 text.split('\n').flatMap(raw => {
172 const line = parseLine(raw)
173 return line ? [line] : []
174 })
175
176export const formatLine = (l: LogLine) =>
177 `${l.time ? `${l.time} ` : ''}${l.pid} ${l.tid} ${l.level} ${l.tag}: ${l.msg}`
178
179// --- Searching --------------------------------------------------------------
180
181export type GrepQuery = { tag?: string; level?: LogLevel; regex?: string; pid?: number }
182
183/** Lines matching every given field: tag (exact or substring), minimum level, regex. */
184export function grep(lines: readonly LogLine[], q: GrepQuery): LogLine[] {
185 const re = q.regex ? new RegExp(q.regex, 'i') : undefined
186 const min = q.level ? levelRank(q.level) : 0
187 const tag = q.tag?.toLowerCase()
188
189 return lines.filter(
190 l =>
191 levelRank(l.level) >= min &&
192 (q.pid === undefined || l.pid === q.pid) &&
193 (tag === undefined || l.tag.toLowerCase().includes(tag)) &&
194 (re === undefined || re.test(l.tag) || re.test(l.msg)),
195 )
196}
197
198/** The pane's filter over the ring: minimum level, then tag-or-message text. */
199export function applyFilter(lines: readonly LogLine[], f: LogcatFilter): LogLine[] {
200 const min = levelRank(f.level)
201 const text = f.text.trim().toLowerCase()
202
203 return lines.filter(
204 l =>
205 levelRank(l.level) >= min &&
206 (text === '' || l.tag.toLowerCase().includes(text) || l.msg.toLowerCase().includes(text)),
207 )
208}
209
210// --- Commands the model runs that name the app ------------------------------
211
212/** The package a command names: `am start -n pkg/...`, `monkey -p pkg`, `pidof pkg`, ... */
213export function packageIn(command: string): string | undefined {
214 const m =
215 /\bam\s+start(?:-activity)?\b[^|;&\n]*?\s-n\s+([A-Za-z][\w.]*)\//.exec(command) ??
216 /\bmonkey\s+-p\s+([A-Za-z][\w.]*)/.exec(command) ??
217 /\bpidof\s+(?:-s\s+)?([A-Za-z][\w.]*\.[\w.]+)/.exec(command)
218
219 return m?.[1]
220}
221
222/** Whether the command (re)starts or reinstalls the app, so its pid is stale. */
223export const restartsApp = (command: string) =>
224 /\b(?:am\s+start(?:-activity)?|am\s+force-stop|monkey\s+-p|adb\b[^|;&\n]*\binstall(?:-multiple)?\b|pm\s+clear)\b/.test(
225 command,
226 )
227
228/** `applicationId = "x"` / `applicationId "x"` in a Gradle build file. */
229export const applicationIdIn = (gradle: string) =>
230 /\bapplicationId\s*=?\s*["']([A-Za-z][\w.]*)["']/.exec(gradle)?.[1]
231
232// --- Which pids are the app's -------------------------------------------------
233
234/**
235 * The app's pids over the session. A pid that was the app's keeps the lines it
236 * logged up to `until` (its last line before it was replaced), so a pid the
237 * system later hands to another process does not bring that process's lines in.
238 * There is no "newest pid" by number: pids are reused and can go down.
239 */
240export class AppPids {
241 current: number | undefined
242 /** When `current` was last confirmed (ms); 0 after a restart was seen. */
243 verifiedAt = 0
244 private readonly until = new Map<number, string | undefined>()
245
246 get known(): number[] {
247 return [...this.until.keys()]
248 }
249
250 get earlier(): number[] {
251 return this.known.filter(p => p !== this.current)
252 }
253
254 /** `next` is the app's pid now; answers whether it changed. */
255 adopt(next: number, now: number, lastLineOfCurrent?: string): boolean {
256 this.verifiedAt = now
257 if (next === this.current) return false
258 this.retire(lastLineOfCurrent)
259 this.until.set(next, undefined)
260 this.current = next
261 return true
262 }
263
264 /** The current pid died: it owns its lines up to `lastLine`. */
265 retire(lastLine?: string) {
266 if (this.current === undefined) return
267 this.until.set(this.current, lastLine ?? '')
268 this.current = undefined
269 }
270
271 /** The app restarted (or may have): the current pid is no longer trusted. */
272 distrust() {
273 this.verifiedAt = 0
274 }
275
276 reset() {
277 this.until.clear()
278 this.current = undefined
279 this.verifiedAt = 0
280 }
281
282 /** Whether the line is the app's; with no pid known yet, every line is. */
283 owns(line: LogLine): boolean {
284 if (this.until.size === 0) return true
285 if (!this.until.has(line.pid)) return false
286 const until = this.until.get(line.pid)
287 return until === undefined || until === '' || line.time === '' || line.time <= until
288 }
289}
290
291/** What an ActivityManager line says about `pkg`'s process. */
292export function appEvent(line: LogLine, pkg: string): { kind: 'start' | 'death'; pid: number } | undefined {
293 if (line.tag !== 'ActivityManager') return undefined
294 const start = /\bStart proc (\d+):([\w.]+)\//.exec(line.msg)
295 if (start && start[2] === pkg) return { kind: 'start', pid: Number(start[1]) }
296 const died =
297 /\bProcess ([\w.]+) \(pid (\d+)\) has died/.exec(line.msg) ??
298 /\bKilling (\d+):([\w.]+)\//.exec(line.msg)
299 if (!died) return undefined
300 const [name, n] = died[0].startsWith('Killing') ? [died[2], died[1]] : [died[1], died[2]]
301 return name === pkg ? { kind: 'death', pid: Number(n) } : undefined
302}
303
304/** The pid of `pkg`'s main process in `ps` / `ps -A` output, if listed. */
305export function pidInPs(stdout: string, pkg: string): number | undefined {
306 for (const row of stdout.split('\n')) {
307 const cols = row.trim().split(/\s+/)
308 if (cols.length >= 3 && cols[cols.length - 1] === pkg && /^\d+$/.test(cols[1] ?? '')) return Number(cols[1])
309 }
310 return undefined
311}
312
313/** The first pid `pidof` printed, if any. */
314export const pidInPidof = (stdout: string) => {
315 const first = stdout.trim().split(/\s+/)[0] ?? ''
316 return /^\d+$/.test(first) ? Number(first) : undefined
317}
318
319export const lineKey = (l: LogLine) => `${l.time}\u0000${l.pid}\u0000${l.tid}\u0000${l.level}\u0000${l.tag}\u0000${l.msg}`
320
321/** A threadtime stamp (`MM-DD HH:MM:SS.mmm`) as milliseconds into the year, roughly. */
322export function stampMs(time: string): number | undefined {
323 const m = /^(\d\d)-(\d\d)\s+(\d\d):(\d\d):(\d\d)\.(\d+)/.exec(time)
324 if (!m) return undefined
325 const [, mo, d, h, mi, s, ms] = m.map(Number) as number[]
326 return ((((mo! * 31 + d!) * 24 + h!) * 60 + mi!) * 60 + s!) * 1000 + ms!
327}
328types/index.d.ts 45 lines1export type LogLevel = 'V' | 'D' | 'I' | 'W' | 'E' | 'F'
2
3/** One parsed logcat line (threadtime or brief), already redacted. */
4export type LogLine = {
5 time: string
6 pid: number
7 tid: number
8 level: LogLevel
9 tag: string
10 msg: string
11}
12
13/** The pane's filter: minimum level and a tag-or-text substring. */
14export type LogcatFilter = { level: LogLevel; text: string }
15
16/**
17 * What the pane draws: only the visible window of the module's ring buffer,
18 * already filtered, plus counters. The full ring stays in the module.
19 */
20export type LogcatView = {
21 lines: LogLine[]
22 total: number
23 matched: number
24 pkg?: string
25 pid?: number
26 /** The app's earlier pids this session, whose lines are still shown. */
27 earlierPids: number[]
28 /** The app restarted (or looks to have) and its new pid is not known yet. */
29 isPidStale: boolean
30 /** `spawn` when this mod owns a live `adb logcat` child; else `observe`. */
31 source: 'spawn' | 'observe'
32 redactions: number
33}
34
35declare module 'claude-code' {
36 interface PluginState {
37 'android-logcat': {
38 view: LogcatView
39 filter: LogcatFilter
40 /** tool_use_ids of adb Bash calls, for the session.append safety net. */
41 adbCalls: string[]
42 }
43 }
44}
45