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.

Personal Claude Code mods.
/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
/team shows or hides the pane.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)./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 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./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).claude --plugin-dir ./plugins/team-board
claude plugin validate ./plugins/team-board
claude plugin test ./plugins/team-boardhooks/register.tsx 333 lines1import { 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}
333hooks/parse.ts 323 lines1// "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}
323types/index.d.ts 29 lines1export 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