SLOPSHOPPER

logpane

A pane that tails the files your Bash calls write to, so you can watch long-running output without a second terminal.

panebandguardcommandprocesstimer
v0.4.0MITupdated 2026-10-20benlrice/logpane
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · logpane
│ ┃ logpane ✕ › fix the failing auth test and add an audit log call │ ┃ No files tracked yet. Run a Bash command │ ┃ that redirects to a file, such as `make > ⏺ Read(src/auth.ts) │ ┃ build.log`. ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ 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 │ │ › /logpane │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · logpane
No files tracked yet. Run a Bash command that redirects to a file, such as `make > build.log`.
README

logpane

Watch the files your Bash commands write to, without a second terminal.

logpane is a Claude Code mod. When a Bash command redirects output to a .log file, logpane tails that file. A band above the prompt lists the files, and a pane shows the lines of one file.

logpane

For example, run make > build.log 2>&1 through Claude Code and watch build.log fill in the pane.

Features

  • Automatic. logpane reads each Bash command before it runs and tracks the files it writes to.
  • Live or ended. Each file shows ● while a process is writing to it and ○ when it ends.
  • Background jobs. Output from a command that returned at once stays visible while its process runs.
  • No settings. Nothing to configure.

Install

To try logpane from a clone:

claude --plugin-dir ~/git/logpane

To install it for normal use:

/plugin install logpane --marketplace benlrice/logpane

Enter y to add the marketplace, then select a scope.

Usage

logpane starts with the session. There is nothing to launch.

The band

The band opens by itself when a tracked file goes live. It is one row above the prompt, with one button per file (for example 1 ● build.log 2 ○ test.log) and a ✕ button.

  • Click a button, or press its number key, to open the pane on that file. The number keys work when the band has the keyboard: press ctrl+x then Tab, or click the band.
  • Press ✕ to dismiss the band. It stays hidden until a tracked file goes live again or a new Bash command writes to a tracked file.
  • The band hides when no file is live. It never shares the screen with the pane.

The pane

The pane opens when you press a band button or enter /logpane. It never opens by itself. It has:

  • One button per tracked file. If two files share a name, the button shows dir/name.
  • A header, live: <name> or ended: <name>.
  • The last lines of the selected file.

The pane shows the newest file until you press a button or number key. Then it keeps your selection.

KeyAction
Tab or arrow keys, then EnterMove between the buttons and press one.
1 to 8Select the file with that number.
mMaximize the pane. Press again to restore it.
EscClose the pane and remove ended files. The band returns if a file is live.

Entering /logpane again removes ended files, clears your selection, and opens the pane with the keyboard focus.

How live is decided

logpane checks every 2 seconds with one lsof call. A file is live while a process has it open for writing, or while a foreground Bash command runs against it. A new command, or output in the last 4 seconds, also keeps a file live, so short writes between checks are not missed.

[!NOTE] Without lsof, a file is live for 20 seconds after its last output.

Which files logpane tracks

CommandTracked files
cmd > out.log, cmd >> out.logout.log
`cmd \tee out.log, cmd \tee -a out.log`out.log
cmd > a.log 2> b.loga.log, b.log
&> f.log, &>> f.log, 1> f.log, `>\f.log`f.log
tee [-a -i -p] f1.log f2.log ...Each file operand that ends in .log
> "my build.log", > 'a b.log'The quoted path
`a && b > x.log; c \tee y.log`x.log, y.log
cmd > out.json, `cmd \tee notes.md, cmd > .log`None (the name must end in .log)
2>&1 and other fd duplicationsNone
/dev/null, /dev/stdout, any other path under /dev or /procNone
A path with $VAR, ~, a backtick, * or ?None (logpane cannot expand it)
A redirect in $(...), in backticks or in a heredoc bodyNone

If logpane cannot read part of a command, it skips that part and does not guess. Relative paths resolve from the session's working directory. logpane uses tail -F, so a file that does not exist yet, or that is truncated or rotated, is followed.

[!TIP] To watch other output, send it to a .log file.

Limits

  • Only names that end in .log, in upper or lower case, are tracked. build.log.1, app.log.gz and a file named only .log are not.
  • At most 8 files at once. When a ninth arrives, logpane removes the one attached longest ago and stops its tail.
  • Each file starts from its last 200 lines. logpane keeps the last 300 lines.
  • logpane reads only the redirects in the command text. It does not follow files a program opens by itself, shell variables, globs, cd in a compound command, or command substitutions.
  • Any process with the file open for writing counts as live, not only the command logpane saw. This needs lsof.
  • No scrollback past 300 lines, no search, no filter, no automatic pane open and no status-line hint.

Development

To check and test the mod:

claude plugin validate .
claude plugin test .
Source 6 files
hooks/register.tsx 285 lines
1import { atom, read, update } from 'claude-code'
2import type { Engine, Register } from 'claude-code'
3
4import { cleanLine, extractTargets, resolvePath } from './parse'
5import {
6  attach,
7  detach,
8  dismiss,
9  dropEnded,
10  empty,
11  isBandWanted,
12  output,
13  pick,
14  probed,
15  type Effects,
16  type Registry,
17  type Result,
18} from './registry'
19import { isTrackedFile } from './tracked'
20import { viewFiles } from './view'
21
22const PANE = 'logpane'
23const INITIAL_LINES = 200
24// The Registry's GRACE_MS is two ticks; change them together.
25const TICK_MS = 2_000
26
27const registry = atom({ plugin: 'logpane', key: 'registry' } as const, empty)
28const tick = atom({ plugin: 'logpane', key: 'tick' } as const, 0)
29const size = atom({ plugin: 'logpane', key: 'size' } as const, 'normal')
30const paneOpen = atom({ plugin: 'logpane', key: 'paneOpen' } as const, false)
31
32// Pane sizes: inline body rows and docked body columns. Normal leaves both
33// to the engine's default.
34const SIZES = {
35  normal: {},
36  max: { rows: 52, columns: 156 },
37} as const
38type Size = keyof typeof SIZES
39
40type Env = Engine
41
42let isTicking = false
43const tails = new Map<string, { return: (value: never) => unknown }>()
44const partial = new Map<string, string>()
45
46// Paths from `paths` (Tracked files) that a process has open for writing, or null when lsof
47// cannot run. lsof exits 1 with no output when nothing has the paths open.
48async function probeWriters($: Env, paths: string[]): Promise<Set<string> | null> {
49  const writers = new Set<string>()
50  let out = ''
51  const run = $.process.spawn({ argv: ['lsof', '-Fan', '--', ...paths] })
52  try {
53    for await (const { stream, text } of run) if (stream === 'stdout') out += text
54  } catch {
55    return null
56  }
57  // Exit 0 lists writers; exit 1 is lsof's answer for no open files. Anything else is a failure.
58  const exit = await run.result.catch(() => null)
59  if (exit?.code !== 0 && exit?.code !== 1) return null
60
61  let access = ''
62  for (const line of out.split('\n')) {
63    if (line.startsWith('f')) access = ''
64    else if (line.startsWith('a')) access = line.slice(1)
65    else if (line.startsWith('n') && (access === 'w' || access === 'u')) writers.add(line.slice(1))
66  }
67
68  return writers
69}
70
71let isRefreshing = false
72
73async function refresh($: Env) {
74  if (isRefreshing) return
75  isRefreshing = true
76  try {
77    const before = await read($, registry)
78    if (before.files.length === 0) return
79    const writers = await probeWriters($, before.files.map(file => file.path))
80    const now = await $.clock.now()
81    await commit($, state => probed(state, now, writers))
82  } finally {
83    isRefreshing = false
84  }
85}
86
87// One `update` per operation. The registry is pure, so the updater is safe to
88// re-run; the effects of the final run are the ones applied.
89async function commit($: Env, operation: (state: Registry) => Result) {
90  let effects: Effects = { start: [], stop: [] }
91  await update($, registry, state => {
92    const result = operation(state)
93    effects = result.effects
94
95    return result.state
96  })
97  for (const path of effects.stop) stopTail(path)
98  for (const path of effects.start) startTail($, path)
99}
100
101function stopTail(path: string) {
102  void tails.get(path)?.return(undefined as never)
103  tails.delete(path)
104  partial.delete(path)
105}
106
107function startTail($: Env, path: string) {
108  if (tails.has(path)) return
109  const run = $.process.spawn({
110    argv: ['tail', '-n', String(INITIAL_LINES), '-F', '--', path],
111  })
112  tails.set(path, run)
113
114  void (async () => {
115    try {
116      for await (const { stream, text } of run) {
117        if (tails.get(path) !== run) break
118        if (stream !== 'stdout') continue
119        const parts = ((partial.get(path) ?? '') + text).split('\n')
120        partial.set(path, parts.pop() ?? '')
121        if (parts.length === 0) continue
122        const now = await $.clock.now()
123        const lines = parts.map(cleanLine)
124        await commit($, state => output(state, now, path, lines))
125      }
126    } catch {
127      // tail could not start or was killed with the Tracked file; nothing to show
128    } finally {
129      if (tails.get(path) === run) tails.delete(path)
130    }
131  })()
132}
133
134async function dropEndedFiles($: Env) {
135  const now = await $.clock.now()
136  await commit($, state => dropEnded(state, now))
137}
138
139async function openPane($: Env, extra: { rows?: number; columns?: number } = {}) {
140  const opened = await $.ui.open({ id: PANE, title: 'logpane', focus: true, closeOnEscape: true, ...extra })
141  if (opened.isPlaced) await update($, paneOpen, () => true)
142
143  return opened
144}
145
146// Opens the Pane at normal size on a Pick (null follows Newest). If the Pane
147// cannot be placed, the earlier Pick comes back.
148async function showPane($: Env, path: string | null) {
149  const previous = (await read($, registry)).picked
150  await update($, registry, state => pick(state, path))
151  await update($, size, () => 'normal')
152  const opened = await openPane($)
153  if (!opened.isPlaced) await update($, registry, state => pick(state, previous))
154
155  return opened
156}
157
158export const register: Register = on => {
159  on('session.start', async ($, e, next) => {
160    await $.command.register({
161      name: 'logpane',
162      description: 'Open a pane tailing the files your Bash calls write to',
163    })
164    if (!isTicking) {
165      isTicking = true
166      $.clock.every(TICK_MS, () => {
167        void update($, tick, n => n + 1)
168        refresh($).catch(() => {})
169      })
170    }
171
172    return next(e)
173  })
174
175  on('command.run', { command: 'logpane' }, async $ => {
176    await dropEndedFiles($)
177    const opened = await showPane($, null)
178
179    return opened.isPlaced ? {} : { text: `logpane: the pane could not be placed. ${opened.reason}` }
180  })
181
182  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
183    const targets = typeof e.command === 'string' ? extractTargets(e.command) : []
184    if (targets.length === 0) return next(e)
185
186    const cwd = await $.session.cwd()
187    const paths = [...new Set(targets.map(target => resolvePath(cwd, target)).filter(isTrackedFile))]
188    if (paths.length === 0) return next(e)
189    const isForeground = e.run_in_background !== true
190    const attachedAt = await $.clock.now()
191    await commit($, state => attach(state, attachedAt, paths, isForeground))
192
193    try {
194      return await next(e)
195    } finally {
196      if (isForeground) {
197        const detachedAt = await $.clock.now()
198        await commit($, state => detach(state, detachedAt, paths))
199      }
200    }
201  })
202
203  on('ui.close', async ($, e, next) => {
204    if (e.id === PANE) {
205      await update($, paneOpen, () => false)
206      await dropEndedFiles($)
207    }
208
209    return next(e)
210  })
211
212  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
213    const { Box, Button, Text } = $.ui.resolve(e)
214    await read($, tick)
215    const state = await read($, registry)
216    const level = await read($, size)
217    const now = await $.clock.now()
218
219    if (state.files.length === 0) {
220      return <Text dimColor>No files tracked yet. Run a Bash command that redirects to a file, such as `make > build.log`.</Text>
221    }
222
223    const views = viewFiles(state, now)
224    const shownView = views.find(view => view.isShown)!
225    const resize = async (target: Size) => {
226      const next: Size = level === target ? 'normal' : target
227      await update($, size, () => next)
228      await openPane($, SIZES[next])
229    }
230    const room = Math.max(1, (e.viewport?.rows ?? 24) - 4)
231
232    return (
233      <Box flexDirection="column">
234        <Box justifyContent="flex-end" columnGap={1}>
235          <Button hotkey="m" label={level === 'max' ? '❐' : '□'} onPress={() => resize('max')} />
236        </Box>
237        <Box flexWrap="wrap" columnGap={1}>
238          {views.map((view, i) => (
239            <Button
240              key={view.path}
241              hotkey={String(i + 1)}
242              variant={view.isShown ? 'primary' : undefined}
243              label={`${view.marker} ${view.label}`}
244              onPress={() => update($, registry, current => pick(current, view.path))}
245            />
246          ))}
247        </Box>
248        <Text dimColor>
249          {shownView.isLive ? 'live' : 'ended'}: {shownView.label}
250        </Text>
251        {state.files.find(file => file.path === shownView.path)!.lines.slice(-room).map((line, i) => (
252          <Text key={i} wrap="truncate-end">{line}</Text>
253        ))}
254      </Box>
255    )
256  })
257
258  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
259    const state = await read($, registry)
260    const isOpen = await read($, paneOpen)
261    await read($, tick)
262    const now = await $.clock.now()
263
264    if (e.props.hasSurvey || isOpen || e.props.maxRows < 1 || !isBandWanted(state, now)) return next(e)
265
266    const { Box, Button } = $.ui.resolve(e)
267    const views = viewFiles(state, now)
268    return (
269      <Box justifyContent="space-between">
270        <Box flexWrap="wrap" columnGap={1}>
271          {views.map((view, i) => (
272            <Button
273              key={view.path}
274              hotkey={String(i + 1)}
275              label={`${i + 1} ${view.marker} ${view.label}`}
276              onPress={() => showPane($, view.path)}
277            />
278          ))}
279        </Box>
280        <Button key="dismiss" label="✕" onPress={() => update($, registry, dismiss)} />
281      </Box>
282    )
283  })
284}
285
hooks/parse.ts 228 lines
1// Finds the files a shell command line redirects or tees output to. No shell runs here:
2// anything that needs expansion is skipped rather than guessed.
3
4const UNSAFE = /[$`*?\\{]/
5const KEYWORDS = new Set(['{', '!', 'if', 'then', 'else', 'elif', 'do', 'while', 'until', 'time'])
6const ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/
7
8type Mode = 'none' | 'target' | 'skip'
9
10export function extractTargets(command: string): string[] {
11  const found: string[] = []
12  const heredocs: { delimiter: string; isTabbed: boolean }[] = []
13  let word = ''
14  let hasWord = false
15  let mode: Mode = 'none'
16  let isCommand = true
17  let isTee = false
18  let isFlagsDone = false
19
20  const add = (text: string) => {
21    const isUnsafe = text === '' || UNSAFE.test(text) || text.startsWith('~')
22    if (!isUnsafe && !found.includes(text)) found.push(text)
23  }
24
25  const endWord = () => {
26    if (!hasWord) return
27    const text = word
28    word = ''
29    hasWord = false
30
31    if (mode === 'target') {
32      mode = 'none'
33      add(text)
34    } else if (mode === 'skip') {
35      mode = 'none'
36    } else if (isCommand) {
37      if (KEYWORDS.has(text) || ASSIGNMENT.test(text)) return
38      isCommand = false
39      isTee = text.split('/').pop() === 'tee'
40    } else if (isTee) {
41      if (!isFlagsDone && text.startsWith('-') && text !== '-') {
42        if (text === '--') isFlagsDone = true
43      } else if (text !== '-') {
44        add(text)
45      }
46    }
47  }
48
49  const separate = () => {
50    endWord()
51    mode = 'none'
52    isCommand = true
53    isTee = false
54    isFlagsDone = false
55  }
56
57  const skipBalanced = (from: number, open: string, close: string): number => {
58    let depth = 0
59    let i = from
60    for (; i < command.length; i++) {
61      const c = command[i]
62      if (c === "'" || c === '"') {
63        const end = command.indexOf(c, i + 1)
64        if (end === -1) return command.length
65        i = end
66      } else if (c === open) depth++
67      else if (c === close && --depth === 0) return i + 1
68    }
69    return command.length
70  }
71
72  const skipHeredocBodies = (from: number): number => {
73    let i = from
74    for (const { delimiter, isTabbed } of heredocs) {
75      while (i < command.length) {
76        let end = command.indexOf('\n', i)
77        if (end === -1) end = command.length
78        const line = command.slice(i, end)
79        i = Math.min(end + 1, command.length)
80        if ((isTabbed ? line.replace(/^\t+/, '') : line) === delimiter) break
81      }
82    }
83    heredocs.length = 0
84    return i
85  }
86
87  const readDelimiter = (from: number): number => {
88    let i = from
89    let isTabbed = false
90    if (command[i] === '-') {
91      isTabbed = true
92      i++
93    }
94    while (command[i] === ' ' || command[i] === '\t') i++
95    let delimiter = ''
96    while (i < command.length && !/[\s;&|<>()]/.test(command[i])) {
97      if (command[i] !== "'" && command[i] !== '"' && command[i] !== '\\') delimiter += command[i]
98      i++
99    }
100    heredocs.push({ delimiter, isTabbed })
101    return i
102  }
103
104  for (let i = 0; i < command.length; ) {
105    const c = command[i]
106
107    if (c === ' ' || c === '\t') {
108      endWord()
109      i++
110    } else if (c === '\n') {
111      separate()
112      i = heredocs.length > 0 ? skipHeredocBodies(i + 1) : i + 1
113    } else if (c === '#' && !hasWord) {
114      const end = command.indexOf('\n', i)
115      i = end === -1 ? command.length : end
116    } else if (c === "'") {
117      const end = command.indexOf("'", i + 1)
118      word += command.slice(i + 1, end === -1 ? command.length : end)
119      hasWord = true
120      i = end === -1 ? command.length : end + 1
121    } else if (c === '"') {
122      let j = i + 1
123      while (j < command.length && command[j] !== '"') {
124        if (command[j] === '\\') word += '\\'
125        word += command[j === command.length ? j : command[j] === '\\' ? ++j : j]
126        j++
127      }
128      hasWord = true
129      i = j + 1
130    } else if (c === '\\') {
131      word += command.slice(i, i + 2)
132      hasWord = true
133      i += 2
134    } else if (c === '`') {
135      const end = command.indexOf('`', i + 1)
136      word += '`'
137      hasWord = true
138      i = end === -1 ? command.length : end + 1
139    } else if (c === '$' && (command[i + 1] === '(' || command[i + 1] === '{')) {
140      word += '$'
141      hasWord = true
142      i = command[i + 1] === '(' ? skipBalanced(i + 1, '(', ')') : skipBalanced(i + 1, '{', '}')
143    } else if (c === '(' || c === ')') {
144      separate()
145      i++
146    } else if (c === ';') {
147      separate()
148      i++
149    } else if (c === '|') {
150      separate()
151      i += command[i + 1] === '|' || command[i + 1] === '&' ? 2 : 1
152    } else if (c === '&' && command[i + 1] === '&') {
153      separate()
154      i += 2
155    } else if (c === '&' && command[i + 1] !== '>') {
156      separate()
157      i++
158    } else if (c === '>' || c === '&' || c === '<') {
159      // A bare number glued to the operator is a file descriptor (2>), not an argument.
160      if (hasWord && /^\d+$/.test(word) && (c === '>' || c === '<')) {
161        word = ''
162        hasWord = false
163      } else {
164        endWord()
165      }
166      let op = c
167      i++
168      if (c === '&') {
169        op = '&>'
170        i++
171      }
172      if (op === '<') {
173        if (command[i] === '<' && command[i + 1] === '<') {
174          mode = 'skip'
175          i += 2
176        } else if (command[i] === '<') {
177          i = readDelimiter(i + 1)
178        } else if (command[i] === '(') {
179          i = skipBalanced(i, '(', ')')
180        } else {
181          mode = 'skip'
182        }
183      } else if (command[i] === '(') {
184        i = skipBalanced(i, '(', ')')
185      } else {
186        if (command[i] === '>' && (op === '>' || op === '&>')) i++
187        else if (command[i] === '|' && op === '>') i++
188        if (op === '>' && command[i] === '&') {
189          i++
190          mode = /[0-9-]/.test(command[i] ?? '') ? 'skip' : 'target'
191        } else {
192          mode = 'target'
193        }
194      }
195    } else {
196      word += c
197      hasWord = true
198      i++
199    }
200  }
201  endWord()
202
203  return found
204}
205
206export function resolvePath(cwd: string, path: string): string {
207  const parts: string[] = []
208  const joined = path.startsWith('/') ? path : `${cwd}/${path}`
209
210  for (const part of joined.split('/')) {
211    if (part === '' || part === '.') continue
212    if (part === '..') parts.pop()
213    else parts.push(part)
214  }
215
216  return `/${parts.join('/')}`
217}
218
219// Drops terminal escapes and keeps only what a carriage-return rewrite left visible.
220export function cleanLine(line: string): string {
221  return line
222    .replace(/\r$/, '')
223    .replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, '')
224    .replace(/\x1b\][^\x07]*\x07/g, '')
225    .split('\r')
226    .pop()!
227}
228
hooks/registry.ts 113 lines
1// The registry: every rule about the Tracked files, as pure functions over one
2// value. Operations take `now` and return the new state plus the tails to start
3// or stop; they never spawn, never read a clock, and never mutate their input,
4// so an updater that wraps one is safe to re-run when `update` retries.
5
6import type { Registry, Tracked } from '../types'
7
8export const MAX_FILES = 8
9export const KEEP_LINES = 300
10const LIVE_MS = 20_000
11// Two ticks of the refresh interval (TICK_MS in register.tsx); change them together.
12const GRACE_MS = 4_000
13
14export type { Registry }
15
16/** Paths whose tails the adapter must start or stop. */
17export type Effects = { start: string[]; stop: string[] }
18
19export type Result = { state: Registry; effects: Effects }
20
21export const empty: Registry = { files: [], picked: null, newest: null, dismissed: false }
22
23const none: Effects = { start: [], stop: [] }
24
25// A Tracked file is Live while a process has it open for writing. Without lsof the
26// process is unknown, so recent output stands in for it. Output and a fresh
27// attach count for a short grace, so a writer that reopens the file per line,
28// or one that has not opened it yet, is not missed between probes.
29export const isLive = (file: Tracked, now: number) =>
30  file.foreground > 0 ||
31  file.isWritten === true ||
32  now - file.lastWriteAt < (file.isWritten === null ? LIVE_MS : GRACE_MS) ||
33  now - file.attachedAt < GRACE_MS
34
35/** Whether the Band should show, before the adapter adds its screen conditions. */
36export const isBandWanted = (state: Registry, now: number) =>
37  !state.dismissed && state.files.some(file => isLive(file, now))
38
39/** The index of the Shown file (Pick, else Newest), or the last file when neither is a Tracked file. */
40export const shownIndex = (state: Registry) => {
41  const found = state.files.findIndex(file => file.path === (state.picked ?? state.newest))
42
43  return found === -1 ? state.files.length - 1 : found
44}
45
46// The one un-dismiss rule: Dismiss ends when an Ended file goes Live, or on attach.
47function settle(before: Registry, after: Registry, now: number, isAttach: boolean): Registry {
48  const wentLive = before.files.some(
49    old => !isLive(old, now) && after.files.some(file => file.path === old.path && isLive(file, now)),
50  )
51
52  return isAttach || wentLive ? { ...after, dismissed: false } : after
53}
54
55export function attach(state: Registry, now: number, paths: string[], isForeground: boolean): Result {
56  let files = state.files
57  for (const path of paths) {
58    files = files.some(file => file.path === path)
59      ? files.map(file =>
60          file.path === path
61            ? { ...file, attachedAt: now, foreground: file.foreground + (isForeground ? 1 : 0) }
62            : file,
63        )
64      : [...files, { path, lines: [], foreground: isForeground ? 1 : 0, lastWriteAt: 0, attachedAt: now, isWritten: null }]
65  }
66  const evicted: string[] = []
67  while (files.length > MAX_FILES) {
68    const oldest = files.reduce((a, b) => (b.attachedAt < a.attachedAt ? b : a))
69    evicted.push(oldest.path)
70    files = files.filter(file => file !== oldest)
71  }
72  const picked = state.picked && evicted.includes(state.picked) ? null : state.picked
73  const next = settle(state, { files, picked, newest: paths[paths.length - 1] ?? null, dismissed: state.dismissed }, now, true)
74
75  return { state: next, effects: { start: paths.filter(path => !evicted.includes(path)), stop: evicted } }
76}
77
78export function detach(state: Registry, now: number, paths: string[]): Result {
79  const files = state.files.map(file =>
80    paths.includes(file.path) ? { ...file, foreground: Math.max(0, file.foreground - 1) } : file,
81  )
82
83  return { state: settle(state, { ...state, files }, now, false), effects: none }
84}
85
86export function output(state: Registry, now: number, path: string, lines: string[]): Result {
87  const files = state.files.map(file =>
88    file.path === path ? { ...file, lines: [...file.lines, ...lines].slice(-KEEP_LINES), lastWriteAt: now } : file,
89  )
90
91  return { state: settle(state, { ...state, files }, now, false), effects: none }
92}
93
94/** Record which paths have a writer open: `writers` is that set, or null when lsof could not say. */
95export function probed(state: Registry, now: number, writers: Set<string> | null): Result {
96  const files = state.files.map(file => ({ ...file, isWritten: writers ? writers.has(file.path) : null }))
97
98  return { state: settle(state, { ...state, files }, now, false), effects: none }
99}
100
101export function dropEnded(state: Registry, now: number): Result {
102  const kept = state.files.filter(file => isLive(file, now))
103  const dropped = state.files.filter(file => !kept.includes(file)).map(file => file.path)
104  const picked = state.picked && dropped.includes(state.picked) ? null : state.picked
105
106  return { state: { ...state, files: kept, picked }, effects: { start: [], stop: dropped } }
107}
108
109/** Pick a file, or pass null to follow Newest again. */
110export const pick = (state: Registry, path: string | null): Registry => ({ ...state, picked: path })
111
112export const dismiss = (state: Registry): Registry => ({ ...state, dismissed: true })
113
hooks/tracked.ts 10 lines
1// The Tracked-file policy: which paths logpane tails. A Tracked file is a `.log`
2// name with a stem (case-insensitive), and never a device or process file.
3
4const EXCLUDED = /^\/(dev|proc)(\/|$)/
5const LOG_NAME = /(^|\/)[^/]+\.log$/i
6
7export function isTrackedFile(path: string): boolean {
8  return LOG_NAME.test(path) && !EXCLUDED.test(path)
9}
10
hooks/view.ts 30 lines
1// The view: what the Pane and the Band both show about each Tracked file, as pure
2// data. Each handler builds its own JSX from it, so a label or marker changes in one place.
3
4import { isLive, type Registry, shownIndex } from './registry'
5
6export type FileView = { path: string; label: string; isLive: boolean; marker: '●' | '○'; isShown: boolean }
7
8// Button text for each path: the file name, with its folder only where names collide.
9export function labelsFor(paths: readonly string[]): string[] {
10  const names = paths.map(path => path.split('/').pop() ?? path)
11
12  return paths.map((path, i) => {
13    if (names.filter(name => name === names[i]).length < 2) return names[i]
14    const parent = path.split('/').slice(-2, -1)[0]
15
16    return parent ? `${parent}/${names[i]}` : names[i]
17  })
18}
19
20export function viewFiles(state: Registry, now: number): FileView[] {
21  const labels = labelsFor(state.files.map(file => file.path))
22  const shownAt = shownIndex(state)
23
24  return state.files.map((file, i) => {
25    const live = isLive(file, now)
26
27    return { path: file.path, label: labels[i], isLive: live, marker: live ? '●' : '○', isShown: i === shownAt }
28  })
29}
30
types/index.d.ts 40 lines
1export type Tracked = {
2  /** Absolute path, as the Bash command routed output to it. */
3  path: string
4  /** The last lines read from `tail -F`, oldest first. */
5  lines: string[]
6  /** Foreground Bash calls currently routed to the file. */
7  foreground: number
8  /** When stdout of the tail last produced lines, in ms; 0 for never. */
9  lastWriteAt: number
10  /** When a command last routed output to the file, in ms. */
11  attachedAt: number
12  /** Whether a process has the file open for writing; null when that cannot be checked. */
13  isWritten: boolean | null
14}
15
16export type Registry = {
17  files: Tracked[]
18  /** The path the person Picked, or null to follow Newest. */
19  picked: string | null
20  /** The last target of the most recent Bash call that redirected to a Tracked file. */
21  newest: string | null
22  /** True after the person Dismisses the Band, until a Tracked file goes Live again or a Bash call attaches. */
23  dismissed: boolean
24}
25
26declare module 'claude-code' {
27  interface PluginState {
28    logpane: {
29      /** The Tracked files, Pick, Newest and the Dismiss flag, written together. */
30      registry: Registry
31      /** Bumped on a timer so the Live and Ended markers redraw. */
32      tick: number
33      /** The Pane's size: the default, or maximized. */
34      size: 'normal' | 'max'
35      /** True while the Pane is open; the Band hides then. */
36      paneOpen: boolean
37    }
38  }
39}
40