SLOPSHOPPER

open-last

A band above the prompt with the last file Claude wrote and any local dev server it started: open them in cmux, or stop the servers.

newbandguardcommandtoastprocess
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · open-last
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ 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 › /open ⎿ open-last: Opened /work/app/src/cache.ts in cmux FILE cache.ts [ open ] ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
FILE cache.ts [ open ] ⟨Claude Code's own drawing⟩
README

claude-mods

Personal Claude Code mods.

Install

/plugin marketplace add ColoMiranda/claude-mods
/plugin install team-board@claude-mods
/plugin install open-last@claude-mods
/plugin install copy-draft@claude-mods
/plugin install account-guard@claude-mods
/plugin install recent-chats@claude-mods
/reload-plugins

Mods

  • team-board: a Team pane for agent teams. One row per agent: working, done, or blocked (red, with the reason). Teammate messages fold to one line in the chat. /team shows or hides the pane.
  • open-last: a band above the prompt with the last file Claude wrote and any local dev server it started. open opens it in cmux (else the default app), diff shows what Claude changed in the last turn (in any folder, git or not; shell edits too: sed -i, perl -i, tee, > and >>), and stop kills the server. /open, /last-diff and /stop-servers do the same from the prompt (/diff is Claude Code's own).
  • account-guard: silent while the GitHub account a push from this repo would use can push. Otherwise a line above the prompt names that account (gh's active account or GH_TOKEN for https; for ssh, the key's account once you run /gh-check). Red when that account cannot push to the repo, with a switch button and /gh-switch when another signed-in gh account can; a toast when Claude pushes or opens a PR anyway.
  • copy-draft: a copy button under each code block of Claude's answers (copy 1, copy 2 when there are several; terminal), and /copy-block [n] to copy a block of the last answer from the prompt. The built-in /copy copies the whole answer. A reply button under each table puts one line per row in the prompt box (R22 - , R23 - ...: the row's first cell, or its number), under anything you typed, so you write what to do with each row; /reply-table [n] does the same from the prompt.
  • recent-chats: when Claude opens fresh, a band above the prompt lists this folder's last 5 chats: the /rename name (else Claude's title, else the last prompt), how long ago, and under it a one-line summary of where that chat stopped. Haiku writes each summary from the end of the chat (a few hundred tokens) and it is kept until that chat changes; only sessions with a person at the prompt call the model, never -p runs. Press 1 to 5 in the empty prompt box, or click a row, to resume that chat. When the www mod shows tasks, the chats take the next numbers. Type anything else to start a new chat; the band goes away at the first prompt. Not shown on claude -c or claude -r. Not listed: chats open in another terminal (two sessions would write one chat), and headless runs (claude -p, SDK).

Develop

claude --plugin-dir ./plugins/team-board
claude plugin validate ./plugins/team-board
claude plugin test ./plugins/team-board
Source 3 files
hooks/register.tsx 333 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { OpenLastServer } from '../types'
5import { basename, commandPort, endedTaskIds, isSecret, isTemp, localUrl, normalizePath, outputPath, shellWrites, taskId } from './parse'
6
7const file = atom({ plugin: 'open-last', key: 'file' } as const, null as string | null)
8const servers = atom({ plugin: 'open-last', key: 'servers' } as const, [] as OpenLastServer[])
9const patch = atom({ plugin: 'open-last', key: 'patch' } as const, null as string | null)
10const skipped = atom({ plugin: 'open-last', key: 'skipped' } as const, [] as string[])
11const tooLarge = atom({ plugin: 'open-last', key: 'tooLarge' } as const, [] as string[])
12const patchError = atom({ plugin: 'open-last', key: 'patchError' } as const, null as string | null)
13
14// ponytail: each file this turn touched, by normalized path, as it was before
15// its first Write or Edit: its old text (null: it did not exist), or why the
16// patch leaves it out. Held in memory until the main turn ends: a hot reload
17// mid-turn loses it, and edits a background agent makes after the turn ends
18// count toward the next turn. `landed` holds the paths a Write or Edit
19// changed, so a refused one is never named.
20type Before = { old: string | null } | { skip: 'secret' | 'large' }
21const before = new Map<string, Before>()
22const landed = new Set<string>()
23const MAX_BYTES = 1_000_000
24
25async function remember($: EngineInterface, path: string) {
26  if (!before.has(path)) before.set(path, await recall($, path))
27}
28
29// A file's old text, or why the patch leaves it out. A secret is never read,
30// nor a link to one (a file not there yet has no realPath: its spelling
31// decides). A file that exists but is over 1 MB or cannot be read is too large
32// (exists rejects only a network location: one of those is unreadable too).
33async function recall($: EngineInterface, path: string): Promise<Before> {
34  if (isSecret(path)) return { skip: 'secret' }
35  const stat = await $.fs.stat(path, { resolve: true }).catch(() => undefined)
36  if (stat?.realPath !== undefined && isSecret(stat.realPath)) return { skip: 'secret' }
37  if (stat === undefined && !(await $.fs.exists(path).catch(() => true))) return { old: null }
38  if (stat !== undefined && stat.size > MAX_BYTES) return { skip: 'large' }
39  const text = await $.fs.read(path).catch(() => undefined)
40
41  return typeof text === 'string' && text.length <= MAX_BYTES ? { old: text } : { skip: 'large' }
42}
43
44// One unified patch of what this turn changed, built with the system's diff,
45// written to the user's own temp folder: its path (null when nothing
46// changed), and the files whose diff ran past what one run returns. Throws
47// when a step fails; the scratch copy of old text is removed either way.
48async function writePatch($: EngineInterface, files: ReadonlyArray<readonly [string, string | null]>): Promise<{ path: string | null; tooLarge: string[] }> {
49  const dir = ((await $.env.get('TMPDIR')) ?? '/tmp/').replace(/\/?$/, '/')
50  const stem = `${dir}open-last-${await $.session.id()}`
51  const parts: string[] = []
52  const large: string[] = []
53  try {
54    for (const [path, old] of files) {
55      if (old !== null) await $.fs.write(`${stem}.before`, old)
56      const isThere = await $.fs.exists(path)
57      const ran = await $.process.run([
58        'diff', '-u',
59        '--label', old === null ? '/dev/null' : `a${path}`,
60        '--label', isThere ? `b${path}` : '/dev/null',
61        old === null ? '/dev/null' : `${stem}.before`,
62        isThere ? path : '/dev/null',
63      ])
64      if (ran.exitCode > 1) throw new Error(`diff failed on ${basename(path)}: ${ran.stderr.trim()}`)
65      if (ran.isStdoutTruncated) large.push(path)
66      else parts.push(ran.stdout)
67    }
68  } finally {
69    // The scratch copy holds a file's old text: gone once the patch is built,
70    // or once building it failed.
71    await $.process.run(['rm', '-f', `${stem}.before`])
72  }
73  const text = parts.join('')
74  if (text.trim() === '') return { path: null, tooLarge: large }
75  await $.fs.write(`${stem}.patch`, text)
76
77  return { path: `${stem}.patch`, tooLarge: large }
78}
79
80// cmux when it is on PATH, else the Mac's default app for the file or URL.
81// Only an absolute path or a localhost URL goes through, so a target can never
82// reach `open` or `cmux open` as a flag.
83async function open($: EngineInterface, target: string): Promise<string> {
84  if (!/^(\/|http:\/\/localhost:)/.test(target)) return `Not opening ${target}: not an absolute path or a localhost URL`
85  try {
86    if ((await $.process.run(['cmux', 'open', target])).exitCode === 0) return `Opened ${target} in cmux`
87  } catch {
88    // no cmux on PATH: fall through to the default app
89  }
90  const ran = await $.process.run(['open', target])
91
92  return ran.exitCode === 0 ? `Opened ${target}` : `Could not open ${target}: ${ran.stderr.trim()}`
93}
94
95const names = (paths: readonly string[]) => paths.map(basename).join(', ')
96const fileWord = (count: number) => (count === 1 ? 'file' : 'files')
97
98// The last turn that changed files, in cmux's diff view; without cmux (or
99// with its browser off), the patch file itself. Says what the patch left out,
100// and when the last turn's patch could not be built (the one before stays).
101async function diff($: EngineInterface): Promise<string> {
102  const path = await read($, patch)
103  const secret = await read($, skipped)
104  const large = await read($, tooLarge)
105  const error = await read($, patchError)
106  if (path === null) {
107    if (error !== null) return `Could not build the last turn's patch: ${error}`
108    const only = [secret.length > 0 && `secret files (${names(secret)})`, large.length > 0 && `files too large or unreadable (${names(large)})`].filter(Boolean)
109    return only.length > 0 ? `The last turn changed only ${only.join(' and ')}, so there is nothing to show` : 'No changes recorded yet'
110  }
111  const left = [
112    secret.length > 0 && `${secret.length} secret ${fileWord(secret.length)}: ${names(secret)}`,
113    large.length > 0 && `${large.length} ${fileWord(large.length)} too large or unreadable: ${names(large)}`,
114  ].filter(Boolean)
115  const notes = [error !== null && `the last turn's patch could not be built: ${error}`, left.length > 0 && `skipped ${left.join('; ')}`].filter(Boolean)
116  const note = notes.length > 0 ? ` (${notes.join('; ')})` : ''
117  const what = error === null ? "the last turn's changes" : 'the previous changes'
118  try {
119    if ((await $.process.run(['cmux', 'diff', path, '--title', 'Last turn'])).exitCode === 0) return `Opened ${what} in cmux${note}`
120  } catch {
121    // no cmux on PATH: open the patch instead
122  }
123
124  return `${await open($, path)}${note}`
125}
126
127// A Write or Edit that landed: the band's file, and a change the patch
128// accounts for.
129async function wrote($: EngineInterface, path: string) {
130  landed.add(path)
131  await update($, file, () => path)
132}
133
134// The files a shell command writes, as absolute paths: a relative spelling is
135// the session's cwd's, a ~ is HOME (none: left out), a temp folder's file left out.
136async function shellTargets($: EngineInterface, command: string): Promise<string[]> {
137  const spellings = shellWrites(command)
138  if (spellings.length === 0) return []
139  const cwd = await $.session.cwd()
140  const home = spellings.some(one => one.startsWith('~')) ? await $.env.get('HOME').catch(() => undefined) : undefined
141  const paths = spellings.flatMap(one => {
142    if (one.startsWith('~')) return home === undefined ? [] : [`${home}${one.slice(1)}`]
143    return [one.startsWith('/') ? one : `${cwd}/${one}`]
144  })
145
146  return paths.map(normalizePath).filter(path => !isTemp(path))
147}
148
149// The pids listening on a TCP port; with no port, on any.
150async function listeners($: EngineInterface, port?: number): Promise<string[]> {
151  const found = await $.process.run(port === undefined ? ['lsof', '-nP', '-t', '-iTCP', '-sTCP:LISTEN'] : ['lsof', '-ti', `tcp:${port}`, '-sTCP:LISTEN'])
152  return found.stdout.split(/\s+/).filter(Boolean)
153}
154
155// Kills the server's own pids that still listen on its port, never another
156// process that took the port since, then drops it from the band.
157async function stop($: EngineInterface, server: OpenLastServer): Promise<string> {
158  const listening = await listeners($, server.port)
159  // An entry from before 0.3.3 has no pids: nothing of it is killed.
160  const pids = (server.pids ?? []).filter(pid => listening.includes(pid))
161  if (pids.length > 0) await $.process.run(['kill', ...pids])
162  await update($, servers, list => list.filter(one => one.taskId !== server.taskId))
163
164  return pids.length > 0 ? `Stopped ${server.url}` : `${server.url} was not running`
165}
166
167// ponytail: once a second for 60s, takes the first local URL the task's
168// output prints, else the port its command names, and adopts the server once
169// a pid that listened on no TCP port before the command ran listens there;
170// `stop` kills only those pids. A server slower than that, or one with
171// neither, never shows; a shared daemon that starts listening for the command
172// (Docker's backend) still counts as new, and a server that restarts under a
173// new pid is not stopped. Upgrade path: stop the background task itself.
174function watch($: EngineInterface, id: string, path: string, command: string, old: ReadonlySet<string>) {
175  let tries = 0
176  const named = commandPort(command)
177  const timer = $.clock.every(1000, () => {
178    void (async () => {
179      tries += 1
180      const log = await $.fs.read(path).catch(() => '')
181      const url = localUrl(log)
182      const port = url?.port ?? named
183      const pids = port === undefined ? [] : (await listeners($, port)).filter(pid => !old.has(pid))
184      if (port !== undefined && pids.length > 0) {
185        const server = { taskId: id, url: url?.url ?? `http://localhost:${port}/`, port, pids }
186        await update($, servers, list => [...list.filter(one => one.port !== port), server])
187      }
188      if (pids.length > 0 || tries >= 60) timer.cancel()
189    })()
190  })
191}
192
193export const register: Register = on => {
194  on('session.start', async ($, e, next) => {
195    await $.command.register({ name: 'open', description: 'Open the last file Claude wrote in cmux', immediate: true })
196    await $.command.register({ name: 'stop-servers', description: 'Stop the local servers Claude started', immediate: true })
197    // Not /diff: that is Claude Code's own command, and a built-in's name is
198    // refused (or, registered first, it takes the built-in diff panel away).
199    await $.command.register({ name: 'last-diff', description: "Show the last turn's changes in cmux", immediate: true })
200
201    return next(e)
202  })
203
204  // The last file written, by the main loop or any agent, under the path the
205  // tool lands on: "/p/.env/x/.." is /p/.env.
206  on('tool.call', { tool: 'Write' }, async ($, e, next) => {
207    const path = normalizePath(e.file_path)
208    await remember($, path)
209    const ran = await next(e)
210    if (ran.deny === undefined && ran.isError !== true) await wrote($, path)
211
212    return ran
213  })
214
215  on('tool.call', { tool: 'Edit' }, async ($, e, next) => {
216    const path = normalizePath(e.file_path)
217    await remember($, path)
218    const ran = await next(e)
219    if (ran.deny === undefined && ran.isError !== true) await wrote($, path)
220
221    return ran
222  })
223
224  // The main turn ended: its changes become the patch `diff` shows. A turn
225  // that changed nothing keeps the last patch; one that changed only files
226  // the patch leaves out has none, and `diff` says why. A patch that cannot
227  // be built keeps the last one, and `diff` says that too.
228  on('turn.complete', async ($, e, next) => {
229    const done = await next(e)
230    if (e.agentId !== undefined || before.size === 0) return done
231    const entries = [...before]
232    const changed = new Set(landed)
233    before.clear()
234    landed.clear()
235    const olds = entries.flatMap(([path, one]) => ('old' in one ? [[path, one.old] as const] : []))
236    const leftOut = (why: 'secret' | 'large') => entries.flatMap(([path, one]) => ('skip' in one && one.skip === why && changed.has(path) ? [path] : []))
237    try {
238      const built = olds.length > 0 ? await writePatch($, olds) : { path: null, tooLarge: [] }
239      const secret = leftOut('secret')
240      const large = [...leftOut('large'), ...built.tooLarge]
241      if (built.path !== null || secret.length > 0 || large.length > 0) {
242        await update($, skipped, () => secret)
243        await update($, tooLarge, () => large)
244        await update($, patchError, () => null)
245        await update($, patch, () => built.path)
246      }
247    } catch (error) {
248      await update($, patchError, () => (error instanceof Error ? error.message : String(error)).split('\n')[0] ?? '')
249    }
250
251    return done
252  })
253
254  // A shell command that writes files (sed -i, perl -i, tee, a > or >>
255  // redirect) counts as a Write: each file it names is remembered before it
256  // runs, so the patch shows it, and becomes the band's file. A file in a temp
257  // folder is a log or scratch, never shown. A background command may also be
258  // a server: whatever listens before it runs is not its server. Unknown (no
259  // lsof): no server is tracked.
260  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
261    const targets = await shellTargets($, e.command).catch(() => [])
262    for (const path of targets) await remember($, path)
263    const old = e.run_in_background === true ? await listeners($).catch(() => undefined) : undefined
264    const ran = await next(e)
265    if (ran.deny !== undefined) return ran
266    for (const path of targets) await wrote($, path)
267    const log = outputPath(ran.text ?? '')
268    const id = taskId(ran.text ?? '')
269    if (old !== undefined && log && id) watch($, id, log, e.command, new Set(old))
270
271    return ran
272  })
273
274  // A background task that ended takes its server off the band.
275  on('session.append', async ($, e, next) => {
276    const stored = await next(e)
277    if (e.message.role !== 'user') return stored
278    const text = e.message.content.flatMap(block => (block.type === 'text' && typeof block.text === 'string' ? [block.text] : [])).join('\n')
279    const ended = text.includes('<task-notification>') ? endedTaskIds(text) : []
280    if (ended.length > 0) await update($, servers, list => list.filter(one => !ended.includes(one.taskId)))
281
282    return stored
283  })
284
285  on('command.run', { command: 'open' }, async $ => {
286    const path = await read($, file)
287
288    return { text: path ? await open($, path) : 'No file written yet in this session' }
289  })
290
291  on('command.run', { command: 'last-diff' }, async $ => ({ text: await diff($) }))
292
293  on('command.run', { command: 'stop-servers' }, async $ => {
294    const list = await read($, servers)
295    const done = await Promise.all(list.map(one => stop($, one)))
296
297    return { text: done.length > 0 ? done.join('\n') : 'No servers running' }
298  })
299
300  // These rows over the rest of the band (other plugins' lines), never instead of it.
301  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
302    const path = await read($, file)
303    const list = await read($, servers)
304    const hasPatch = (await read($, patch)) !== null
305    if (e.props.hasSurvey || (path === null && list.length === 0)) return next(e)
306    const { Box, Button, Link, Text } = $.ui.resolve(e)
307    // Nothing beneath (a test's bare engine): these rows alone.
308    const rest = await next(e).catch(() => undefined)
309
310    return (
311      <Box flexDirection="column" marginTop={1}>
312        {path !== null && (
313          <Box gap={1}>
314            <Text dimColor>FILE</Text>
315            <Text wrap="truncate-middle">{basename(path)}</Text>
316            <Button key="open-file" label="open" hotkey="o" onPress={async () => $.ui.toast(await open($, path))} />
317            {hasPatch && <Button key="diff" label="diff" hotkey="d" onPress={async () => $.ui.toast(await diff($))} />}
318          </Box>
319        )}
320        {list.map((server, i) => (
321          <Box gap={1}>
322            <Text dimColor>SERVER</Text>
323            <Link href={server.url} />
324            <Button key={`open-${server.taskId}`} label="open" hotkey={i === 0 ? 's' : undefined} onPress={async () => $.ui.toast(await open($, server.url))} />
325            <Button key={`stop-${server.taskId}`} label="stop" hotkey={i === 0 ? 'x' : undefined} onPress={async () => $.ui.toast(await stop($, server))} />
326          </Box>
327        ))}
328        {rest}
329      </Box>
330    )
331  })
332}
333
hooks/parse.ts 323 lines
1// "Output is being written to: /…/tasks/bwlbmkhlu.output." in a background
2// Bash result's text.
3export function outputPath(text: string): string | undefined {
4  return /Output is being written to: (\S+?\.output)\b/.exec(text)?.[1]
5}
6
7export function taskId(text: string): string | undefined {
8  return /running in background with ID: (\w+)/.exec(text)?.[1]
9}
10
11const ANSI = /\x1b\[[0-9;?]*[A-Za-z]/g
12const LOCAL_URL = /https?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1?\]):(\d{2,5})(\/[^\s'"<>)\]]*)?/
13
14// The first local URL a dev server printed, as a Link takes it:
15// "  ➜  Local:   http://127.0.0.1:4321/docs" -> http://localhost:4321/docs.
16export function localUrl(log: string): { url: string; port: number } | undefined {
17  const m = LOCAL_URL.exec(log.replace(ANSI, ''))
18  if (!m) return undefined
19  const port = Number(m[1])
20  return { url: `http://localhost:${port}${m[2] ?? '/'}`, port }
21}
22
23// The port a server command names, for a server whose output is buffered
24// (python's http.server prints nothing until it exits): "--port 4321",
25// "-p 3000", "PORT=8080", "http.server 8765"; a bare http.server is 8000.
26// Each flag stands alone (DB_PORT=, PGPORT= and --db-port are not it), and a
27// "-p 5432:5432" mapping (docker) names no port of its own.
28export function commandPort(command: string): number | undefined {
29  const m = /(?:^|\s)(?:PORT=|--port[= ]|-p )["']?(\d{2,5})(?![\w:.])/.exec(command) ?? /http\.server (\d{2,5})\b/.exec(command)
30  if (m) return Number(m[1])
31  return /http\.server\b/.test(command) ? 8000 : undefined
32}
33
34export function basename(path: string): string {
35  return path.slice(path.lastIndexOf('/') + 1)
36}
37
38// "/p/.env/x/.." -> "/p/.env", "/p//a/./b/" -> "/p/a/b": the file a spelling
39// names once ".", ".." and repeated or trailing slashes are folded, as the
40// Write tool and $.fs fold them (links aside).
41export function normalizePath(path: string): string {
42  const isAbsolute = path.startsWith('/')
43  const parts: string[] = []
44  for (const part of path.split('/')) {
45    if (part === '' || part === '.') continue
46    if (part !== '..') parts.push(part)
47    else if (parts.length > 0 && parts.at(-1) !== '..') parts.pop()
48    else if (!isAbsolute) parts.push('..')
49  }
50
51  return isAbsolute ? `/${parts.join('/')}` : parts.join('/') || '.'
52}
53
54const TEMPLATE = /\.(?:example|sample|template|dist|defaults)$/
55const CODE = /\.(?:[cm]?[jt]sx?|py|rb|go|rs|java|kt|swift|php|cs|c|h|cc|cpp|hpp|scala|zig)$/
56const KEY_PART = /(?:^|[._-])(?:rsa|dsa|ecdsa|ed25519)(?:[._-]|$)/
57const ID_KEY = /^id_(?:rsa|dsa|ecdsa|ed25519)/
58const SECRET_NAMES = new Set(['.envrc', '.git-credentials', '.netrc', '.npmrc', '.pypirc', '.pgpass', 'credentials'])
59const SECRET_EXTENSION = /\.(?:pem|key|p12|pfx|jks|keystore)$/
60
61// ponytail: a fixed list of names, any case, on the normalized path: .env,
62// *.env and .env.* (not a template ending in .example, .sample, .template,
63// .dist or .defaults, nor code such as .env.ts); a private key by its
64// algorithm, in a name with no extension (github_rsa, id_ed25519_work) or
65// one that starts id_<algorithm> (id_rsa.bak), never a .pub; so rsa.md and
66// rsa_keygen.sh stay visible; or by
67// extension (.pem, .key, ...); credential files (.envrc, .git-credentials,
68// .netrc, .npmrc, .pypirc, .pgpass, credentials, .docker/config.json,
69// service-account*.json). Content is not scanned, so a secret copied into a
70// normal file (cp .env config.txt) still reaches the patch: the accepted
71// limit. Upgrade path: scan the old and new text for key patterns.
72export function isSecret(path: string): boolean {
73  const lower = normalizePath(path).toLowerCase()
74  const name = basename(lower)
75  if (name.endsWith('.env')) return true
76  if (name.startsWith('.env.')) return !TEMPLATE.test(name) && !CODE.test(name)
77  if (!name.endsWith('.pub') && (ID_KEY.test(name) || (KEY_PART.test(name) && !name.includes('.')))) return true
78
79  return (
80    SECRET_NAMES.has(name) ||
81    SECRET_EXTENSION.test(name) ||
82    `/${lower}`.endsWith('/.docker/config.json') ||
83    /^service[-_]account.*\.json$/.test(name)
84  )
85}
86
87// A shell word: quotes and escapes folded in. Dynamic when an unquoted $, `,
88// glob or brace makes its value known only at run time.
89type Word = { word: string; isDynamic: boolean }
90type Token = Word | { op: string }
91
92// Longest first, so ">>" is never read as two ">".
93const OPS = ['&>>', '<<-', '<<<', '&&', '||', '>>', '>|', '&>', '>&', '<&', '<<', ';', '|', '&', '(', ')', '<', '>', '\n']
94const REDIRECT_OUT = new Set(['>', '>>', '>|', '&>', '&>>', '>&'])
95const REDIRECT_IN = new Set(['<', '<<', '<<-', '<<<', '<&'])
96// Words that run the command after them: `sudo sed -i ...` is sed.
97const PREFIXES = new Set(['sudo', 'command', 'env', 'nohup', 'time', 'exec', '{', '!'])
98
99// The index of the ")" that closes the "(" at `open`, quotes skipped.
100function closing(text: string, open: number): number {
101  let depth = 0
102  for (let i = open; i < text.length; i++) {
103    const c = text[i]
104    if (c === "'" || c === '"') {
105      const close = text.indexOf(c, i + 1)
106      if (close === -1) break
107      i = close
108    } else if (c === '(') depth += 1
109    else if (c === ')' && --depth === 0) return i
110  }
111  return text.length - 1
112}
113
114// A command's words and operators, as sh splits them. A heredoc's body is
115// dropped, so a ">" inside it is never read as a redirect; a digit word right
116// before a redirect is its file descriptor ("2>err"), dropped too.
117function tokenize(command: string): Token[] {
118  const tokens: Token[] = []
119  const heredocs: Array<{ tag: string; isTabbed: boolean }> = []
120  let awaitsTag: boolean | undefined
121  let word = ''
122  let hasWord = false
123  let isDynamic = false
124  let isQuoted = false
125  const end = () => {
126    if (hasWord) {
127      tokens.push({ word, isDynamic })
128      if (awaitsTag !== undefined) heredocs.push({ tag: word, isTabbed: awaitsTag })
129      awaitsTag = undefined
130    }
131    word = ''
132    hasWord = false
133    isDynamic = false
134    isQuoted = false
135  }
136  let i = 0
137  while (i < command.length) {
138    const c = command[i]!
139    if (c === '\\') {
140      if (command[i + 1] !== '\n') word += command[i + 1] ?? ''
141      hasWord ||= command[i + 1] !== '\n'
142      isQuoted = true
143      i += 2
144    } else if (c === "'") {
145      const close = command.indexOf("'", i + 1)
146      const stop = close === -1 ? command.length : close
147      word += command.slice(i + 1, stop)
148      hasWord = isQuoted = true
149      i = stop + 1
150    } else if (c === '"') {
151      for (i += 1; i < command.length && command[i] !== '"'; i++) {
152        if (command[i] === '\\' && '"\\$`\n'.includes(command[i + 1] ?? 'x')) i += 1
153        else if (command[i] === '$' || command[i] === '`') isDynamic = true
154        word += command[i]
155      }
156      hasWord = isQuoted = true
157      i += 1
158    } else if (c === '$' && command[i + 1] === '(') {
159      const stop = closing(command, i + 1)
160      word += command.slice(i, stop + 1)
161      hasWord = isDynamic = true
162      i = stop + 1
163    } else if (c === '`') {
164      const close = command.indexOf('`', i + 1)
165      const stop = close === -1 ? command.length : close
166      word += command.slice(i, stop + 1)
167      hasWord = isDynamic = true
168      i = stop + 1
169    } else if (c === '#' && !hasWord) {
170      while (i < command.length && command[i] !== '\n') i += 1
171    } else if (c === ' ' || c === '\t') {
172      end()
173      i += 1
174    } else {
175      const op = OPS.find(one => command.startsWith(one, i))
176      if (op === undefined) {
177        if ('$*?[{'.includes(c)) isDynamic = true
178        word += c
179        hasWord = true
180        i += 1
181        continue
182      }
183      if ((REDIRECT_OUT.has(op) || REDIRECT_IN.has(op)) && /^\d+$/.test(word) && !isQuoted) hasWord = false
184      end()
185      tokens.push({ op })
186      i += op.length
187      if (op === '<<' || op === '<<-') awaitsTag = op === '<<-'
188      if (op !== '\n') continue
189      // The heredoc bodies start on the line after their "<<TAG".
190      for (const { tag, isTabbed } of heredocs.splice(0)) {
191        while (i < command.length) {
192          const nl = command.indexOf('\n', i)
193          const stop = nl === -1 ? command.length : nl
194          const line = command.slice(i, stop)
195          i = stop + 1
196          if ((isTabbed ? line.replace(/^\t+/, '') : line) === tag) break
197        }
198      }
199    }
200  }
201  end()
202
203  return tokens
204}
205
206// The files sed -i, perl -i or tee writes, from its arguments: sed's or
207// perl's in-place flag in any cluster (-i, -i.bak, -Ei, -pi), BSD sed's
208// "-i ''" suffix, and the script that comes first unless -e or -f gave it.
209function editedFiles(name: string, args: readonly Word[]): Word[] {
210  if (name === 'tee') return args.filter(one => !one.word.startsWith('-'))
211  if (name !== 'sed' && name !== 'perl') return []
212  let isInPlace = false
213  let hasScript = false
214  const positional: Word[] = []
215  for (let i = 0; i < args.length; i++) {
216    const { word } = args[i]!
217    if (word === '--') {
218      positional.push(...args.slice(i + 1))
219      break
220    }
221    if (word.startsWith('--')) {
222      if (word.startsWith('--in-place')) isInPlace = true
223      else if (/^--(?:expression|file)=/.test(word)) hasScript = true
224      else if (word === '--expression' || word === '--file') hasScript = Boolean(++i)
225      continue
226    }
227    if (!word.startsWith('-') || word === '-') {
228      positional.push(args[i]!)
229      continue
230    }
231    for (let j = 1; j < word.length; j++) {
232      const flag = word[j]!
233      const isLast = j === word.length - 1
234      if (flag === 'i') {
235        isInPlace = true
236        const suffix = args[i + 1]?.word
237        if (isLast && name === 'sed' && suffix !== undefined && (suffix === '' || /^\.\w+$/.test(suffix))) i += 1
238        break
239      }
240      if (flag === 'e' || flag === 'f' || (name === 'perl' && flag === 'E')) {
241        hasScript = true
242        if (isLast) i += 1
243        break
244      }
245      // perl flags that take the rest of the cluster as their value.
246      if (name === 'perl' && /[0lMmIxCdD]/.test(flag)) break
247    }
248  }
249  if (!isInPlace) return []
250
251  return hasScript ? positional : positional.slice(1)
252}
253
254// ponytail: the files a shell command writes, as spelled, with a literal
255// `cd` before them folded in ("cd /p && sed -i '' s/a/b/ x" is /p/x): the
256// targets of > and >> (and &>, >|), tee's files, and sed -i or perl -i's
257// files, in each command of a list, pipeline or subshell. A ~ stays for the
258// caller to expand; a relative spelling is the caller's cwd's. Not followed: a
259// glob, a variable or a $(...) in the target, a target after a `cd` that is
260// not literal, find -exec and xargs, a script that writes files (python,
261// node), cp, mv and rm. Upgrade path: snapshot the tree before each command.
262export function shellWrites(command: string): string[] {
263  const found: string[] = []
264  const dirs: Array<string | undefined> = []
265  // '' is the caller's cwd; undefined, a `cd` this cannot follow.
266  let dir: string | undefined = ''
267  let words: Word[] = []
268  const take = ({ word, isDynamic }: Word) => {
269    if (isDynamic || word === '' || word.startsWith('/dev/')) return
270    const isRooted = word.startsWith('/') || word === '~' || word.startsWith('~/')
271    if (isRooted) found.push(word)
272    else if (dir !== undefined) found.push(dir === '' ? word : `${dir}/${word}`)
273  }
274  const flush = () => {
275    let first = 0
276    while (first < words.length && (PREFIXES.has(words[first]!.word) || /^\w+=/.test(words[first]!.word))) first += 1
277    const [head, ...args] = words.slice(first)
278    words = []
279    if (head === undefined) return
280    if (head.word !== 'cd') return void editedFiles(basename(head.word), args).forEach(take)
281    const to = args[0]
282    if (to === undefined) dir = '~'
283    else if (args.length > 1 || to.isDynamic || to.word === '-') dir = undefined
284    else if (to.word.startsWith('/') || to.word === '~' || to.word.startsWith('~/')) dir = to.word
285    else if (dir !== undefined) dir = dir === '' ? to.word : `${dir}/${to.word}`
286  }
287  const tokens = tokenize(command)
288  for (let i = 0; i < tokens.length; i++) {
289    const token = tokens[i]!
290    if ('word' in token) {
291      words.push(token)
292      continue
293    }
294    const operand = tokens[i + 1]
295    const hasOperand = operand !== undefined && 'word' in operand
296    if (REDIRECT_OUT.has(token.op) || REDIRECT_IN.has(token.op)) {
297      // ">&2" and ">&-" name a descriptor; ">& file" is bash's "&> file".
298      if (hasOperand && REDIRECT_OUT.has(token.op) && !(token.op === '>&' && /^(?:\d+|-)$/.test(operand.word))) take(operand)
299      if (hasOperand) i += 1
300      continue
301    }
302    flush()
303    if (token.op === '(') dirs.push(dir)
304    if (token.op === ')' && dirs.length > 0) dir = dirs.pop()
305  }
306  flush()
307
308  return [...new Set(found)]
309}
310
311// A temp folder's file: a command's log or scratch output, not a change to show.
312export function isTemp(path: string): boolean {
313  return /^\/(?:private\/)?(?:tmp|var\/folders)\//.test(path)
314}
315
316// The task ids a <task-notification> row reports as ended (completed,
317// failed, killed): their servers are gone.
318export function endedTaskIds(text: string): string[] {
319  return [...text.matchAll(/<task-notification>[\s\S]*?<task-id>(\w+)<\/task-id>[\s\S]*?<status>(\w+)<\/status>/g)]
320    .filter(([, , status]) => status !== 'running')
321    .map(([, id = '']) => id)
322}
323
types/index.d.ts 29 lines
1export type OpenLastServer = {
2  // The background Bash task's id.
3  taskId: string
4  // Normalized for a Link: http://localhost:<port>/<path>.
5  url: string
6  port: number
7  // The pids that began listening on the port after the command started:
8  // the only ones `stop` kills.
9  pids: string[]
10}
11
12declare module 'claude-code' {
13  interface PluginState {
14    // patch: the last turn's changes as a unified patch file, for `diff`.
15    // skipped: the secret files (paths) that turn changed, left out of it.
16    // tooLarge: the files (paths) left out of it for their size.
17    // patchError: why the latest turn's patch could not be built (the last
18    // good one stays), or null.
19    'open-last': {
20      file: string | null
21      servers: OpenLastServer[]
22      patch: string | null
23      skipped: string[]
24      tooLarge: string[]
25      patchError: string | null
26    }
27  }
28}
29