SLOPSHOPPER

android-logcat

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.

paneguardcommandtoasttoolprocess
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · android-logcat
│ ┃ logcat · all apps ✕ › fix the failing auth test and add an audit log call │ ┃ app package unknown · all pids · from adb │ ┃ calls · 0/0 lines · 0 redacted ⏺ Read(src/auth.ts) │ ┃ Level: V ▾ Filter: tag or text ⏎ filter ⎿ Read 6 lines │ ┃ [ Clear ] [ Send to Claude ] ⏺ Update(src/auth.ts) │ ┃ No lines yet. They arrive from adb logcat ⎿ Added 2 lines, removed 1 line │ ┃ calls. ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /logcat │ ⎿ android-logcat: Opened the logcat pane (0 buffered lines). │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · logcat · all apps
app package unknown · all pids · from adb calls · 0/0 lines · 0 redacted Level: V ▾ Filter: tag or text ⏎ filter [ Clear ] [ Send to Claude ] No lines yet. They arrive from adb logcat calls.
README

claude-android-mods

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.

ModWhat it gives you
build-output-distillerGradle 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-logcatBare 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-consoleA 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-lensA 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.

Requirements

  • Claude Code 2.1.299 or newer. Function-hook mods are early access, and older builds reject some of the events these mods use. Check with claude --version.
  • The Android SDK's adb on your PATH, or ANDROID_HOME / ANDROID_SDK_ROOT set, for the three mods that talk to a device.
  • An Android or KMP project as the session's working directory.

Install

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

Run

There's nothing to start. Open Claude Code in your Android project and the mods work on their own:

  • build-output-distiller: ask Claude to build or test (./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.
  • android-logcat: when Claude runs 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.
  • android-device-console: with a device or emulator connected, run /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.
  • android-project-lens: ask about navigation ("which routes take an id?") and the model uses nav_graph. Run /stability to compile once with Compose compiler reports, or /stability read to read existing reports.

Permission prompts in the desktop app

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.

Choosing a device

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.

Options

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.

Status

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 ?.
  • Forced RTL uses a per-app Hebrew locale on API 33+ and needs the app's package. Below API 33 it falls back to debug.force_rtl, which may need a reboot. Layout bounds use a service call that some system images ignore.
  • android-project-lens's /stability Gradle init script has not yet been run against every AGP and Kotlin version.

Issues and pull requests are welcome.

Develop

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.

License

MIT

Source 3 files
hooks/register.tsx 638 lines
1import { 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}
638
hooks/logcat.ts 328 lines
1// 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}
328
types/index.d.ts 45 lines
1export 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