Live timeline of every tool call Claude makes: a band above the prompt, a timeline pane, alerts when Claude loops or edits outside the project, and a Markdown…

See what Claude is doing while it works. Stepscope records every tool call in the current session and shows it live: a one-line band above the prompt with step, edit and failure counts plus the step running now, and a timeline pane you open with /fr. It warns you when Claude looks stuck or edits files outside your project, and exports a clean Markdown log you can read before you commit or paste into a pull request.

claude -p, /fr prints a text summary instead.| Command | What it does |
|---|---|
/fr | Open or close the timeline pane |
/fr export [path] | Write the session log as Markdown (default flight-recorder-YYYYMMDD-HHMM.md in the project root). Never overwrites a file. |
/fr clear | Start the timeline over |
Set with /plugin configure stepscope@<marketplace>:
| Option | Default | Meaning |
|---|---|---|
failStreak | 3 | Failures in a row before the stuck-loop alert |
longStepSeconds | 60 | Seconds before a running step turns the band amber |
showBand | true | Show the band above the prompt |
/fr export [path] or press Export. The default path is flight-recorder-YYYYMMDD-HHMM.md in the project root; with [path] it is the path you type, resolved against the project root. It refuses to overwrite an existing file, and it never writes build, start-up, settings or instruction files. The reply shows the exact path written.*_TOKEN=/*_PASSWORD= style assignments, Authorization headers, passwords in URLs). Redaction is best effort: read an exported log before you share it.claude plugin test # unit and hook tests
claude plugin validate --strict .
claude --plugin-dir . # try it live; edits hot-reload
MIT
hooks/register.tsx 190 lines1import { DEFAULT_CONFIG, type AlertConfig, type AlertState, createAlertState, onStepFinish, onStepStart } from '../src/alerts.ts'
2import { exportFileName, toMarkdown } from '../src/export.ts'
3import { describeCall } from '../src/labels.ts'
4import { resolvePath } from '../src/paths.ts'
5import { bandSegments, paneView, summaryText } from '../src/render.ts'
6import { type Step, type Timeline, createTimeline, currentStep, finishStep, startStep } from '../src/timeline.ts'
7
8const PANE_ID = 'flight-recorder'
9const INLINE_ROWS = 12
10const USAGE = 'Usage: /fr [export [path] | clear]'
11
12let timeline: Timeline = createTimeline()
13let alerts: AlertState = createAlertState()
14let config: AlertConfig = { ...DEFAULT_CONFIG }
15let showBand = true
16let ticker: { cancel: () => void } | null = null
17
18function reset() {
19 timeline = createTimeline()
20 alerts = createAlertState()
21}
22
23function positive(value: unknown, fallback: number): number {
24 const n = Number(value)
25 return Number.isFinite(n) && n > 0 ? n : fallback
26}
27
28function failureOf(result: unknown): { failed: boolean; error?: string } {
29 if (!result || typeof result !== 'object') return { failed: false }
30 const r = result as { deny?: unknown; isError?: unknown; text?: unknown }
31 if (typeof r.deny === 'string') return { failed: true, error: r.deny }
32 if (r.isError === true) return { failed: true, error: typeof r.text === 'string' ? r.text : undefined }
33 return { failed: false }
34}
35
36async function startTicker($) {
37 if (ticker) return
38 ticker = $.clock.every(1000, () => {
39 if (!currentStep(timeline) && ticker) {
40 ticker.cancel()
41 ticker = null
42 }
43 $.ui.invalidate('ui.render')
44 })
45}
46
47async function finish($, step: Step | null, failed: boolean, error?: string) {
48 if (!step) return
49 let now = step.startedAt
50 try {
51 now = await $.clock.now()
52 } catch {}
53 finishStep(step, failed ? 'fail' : 'ok', now, error)
54 const warning = onStepFinish(alerts, step, config)
55 if (warning) $.ui.toast(warning)
56 $.ui.invalidate('ui.render')
57}
58
59async function exportLog($, arg: string): Promise<string> {
60 const now = await $.clock.now()
61 const root = await $.session.root()
62 const target = resolvePath(arg || exportFileName(now), root)
63 if (await $.fs.exists(target)) return 'Not written: ' + target + ' already exists.'
64 try {
65 await $.fs.write(target, toMarkdown(timeline, now))
66 } catch (err) {
67 return 'Export failed: ' + (err instanceof Error ? err.message : String(err))
68 }
69 const n = timeline.steps.length
70 return 'Exported ' + n + (n === 1 ? ' step' : ' steps') + ' to ' + target
71}
72
73async function togglePane($): Promise<{ text?: string }> {
74 const surfaces = await $.session.surfaces()
75 if (surfaces.length === 0) return { text: summaryText(timeline, await $.clock.now()) }
76 const panes = await $.ui.panes()
77 if (panes.some(p => p.id === PANE_ID)) {
78 await $.ui.close({ id: PANE_ID })
79 return {}
80 }
81 const opened = await $.ui.open({ id: PANE_ID, title: 'Flight Recorder', rows: INLINE_ROWS })
82 return opened.isPlaced ? {} : { text: 'Pane not shown: ' + (opened.reason ?? 'not enough room') }
83}
84
85export function register(on, options) {
86 config = {
87 failStreak: positive(options?.failStreak, DEFAULT_CONFIG.failStreak),
88 longStepSeconds: positive(options?.longStepSeconds, DEFAULT_CONFIG.longStepSeconds),
89 }
90 showBand = options?.showBand !== false && options?.showBand !== 'false'
91
92 on('session.start', async ($, e, next) => {
93 await $.command.register({
94 name: 'fr',
95 description: 'Flight recorder: toggle the timeline pane, export it as Markdown, or clear it',
96 argumentHint: '[export [path] | clear]',
97 immediate: true,
98 })
99 return next(e)
100 }).catch(($, e, next) => next(e))
101
102 on('session.end', async ($, e, next) => {
103 reset()
104 if (ticker) {
105 ticker.cancel()
106 ticker = null
107 }
108 return next(e)
109 }).catch(($, e, next) => next(e))
110
111 on('tool.call', async ($, e, next) => {
112 let step: Step | null = null
113 try {
114 const cwd = await $.session.cwd()
115 const root = await $.session.root()
116 const id = e.tool_use_id ?? 'step-' + (timeline.steps.length + timeline.dropped + 1)
117 step = startStep(timeline, describeCall(id, e.tool, e as Record<string, unknown>, cwd, root), await $.clock.now())
118 const warning = onStepStart(alerts, step, root)
119 if (warning) $.ui.toast(warning)
120 await startTicker($)
121 $.ui.invalidate('ui.render')
122 } catch (err) {
123 $.ui.log('stepscope: ' + String(err), { to: 'debug' })
124 }
125 let result
126 try {
127 result = await next(e)
128 } catch (err) {
129 try {
130 await finish($, step, true, err instanceof Error ? err.message : String(err))
131 } catch {}
132 throw err
133 }
134 try {
135 const outcome = failureOf(result)
136 await finish($, step, outcome.failed, outcome.error)
137 } catch (err) {
138 $.ui.log('stepscope: ' + String(err), { to: 'debug' })
139 }
140 return result
141 })
142
143 on('command.run', { command: 'fr' }, async ($, e) => {
144 const args = (e.args ?? '').trim()
145 const sub = args.split(/\s+/)[0]
146 if (sub === 'export') return { text: await exportLog($, args.slice('export'.length).trim()) }
147 if (sub === 'clear') {
148 reset()
149 $.ui.invalidate('ui.render')
150 return { text: 'Timeline cleared.' }
151 }
152 if (sub) return { text: USAGE }
153 return togglePane($)
154 })
155
156 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
157 const below = await next(e)
158 if (!showBand) return below
159 const segments = bandSegments(timeline, await $.clock.now(), config, e.props.bodyColumns)
160 if (!segments) return below
161 const { Box, Text } = $.ui.resolve(e)
162 return (
163 <Box flexDirection="column">
164 <Box>
165 {segments.map((seg, i) => <Text key={'b' + i} color={seg.color}>{seg.text}</Text>)}
166 </Box>
167 {below}
168 </Box>
169 )
170 }).catch(($, e, next) => next(e))
171
172 on('ui.render', { component: 'Pane' }, async ($, e, next) => {
173 if (e.requestId !== PANE_ID) return next(e)
174 const { Box, Text, Button } = $.ui.resolve(e)
175 const rowsAvailable = (e.props.placement === 'dock' ? e.props.scroll.bodyRows : INLINE_ROWS) - 3
176 const view = paneView(timeline, await $.clock.now(), e.props.bodyColumns, rowsAvailable)
177 return (
178 <Box flexDirection="column">
179 <Text bold>{view.header}</Text>
180 <Box gap={2}>
181 <Button key="export" label="Export" onPress={async () => $.ui.toast(await exportLog($, ''))} />
182 <Button key="clear" label="Clear" onPress={() => { reset(); $.ui.invalidate('ui.render') }} />
183 </Box>
184 {view.earlier ? <Text dimColor>{view.earlier}</Text> : null}
185 {view.rows.map((row, i) => <Text key={'r' + i} color={row.color}>{row.text}</Text>)}
186 </Box>
187 )
188 })
189}
190src/alerts.ts 54 lines1import { isInside } from './paths.ts'
2import type { Step } from './timeline.ts'
3
4export interface AlertConfig {
5 failStreak: number
6 longStepSeconds: number
7}
8
9export const DEFAULT_CONFIG: AlertConfig = { failStreak: 3, longStepSeconds: 60 }
10
11export interface AlertState {
12 streakCommand?: string
13 streakCount: number
14 streakFired: boolean
15 warnedPaths: Set<string>
16}
17
18export function createAlertState(): AlertState {
19 return { streakCount: 0, streakFired: false, warnedPaths: new Set() }
20}
21
22export function onStepStart(state: AlertState, step: Step, root: string): string | null {
23 if (step.kind !== 'edit' || !step.path) return null
24 if (isInside(step.path, root) || state.warnedPaths.has(step.path)) return null
25 state.warnedPaths.add(step.path)
26 return 'Edit outside the project: ' + step.path
27}
28
29export function onStepFinish(state: AlertState, step: Step, cfg: AlertConfig): string | null {
30 if (step.kind !== 'bash') return null
31 if (step.status !== 'fail') {
32 state.streakCommand = undefined
33 state.streakCount = 0
34 state.streakFired = false
35 return null
36 }
37 if (step.command !== state.streakCommand) {
38 state.streakCommand = step.command
39 state.streakCount = 1
40 state.streakFired = false
41 } else {
42 state.streakCount++
43 }
44 if (state.streakCount >= cfg.failStreak && !state.streakFired) {
45 state.streakFired = true
46 return 'Same command failed ' + state.streakCount + '× — Claude may be stuck: ' + step.label
47 }
48 return null
49}
50
51export function isSlow(step: Step, now: number, cfg: AlertConfig): boolean {
52 return step.status === 'running' && now - step.startedAt >= cfg.longStepSeconds * 1000
53}
54src/export.ts 55 lines1import { formatClock, formatDuration, plural } from './format.ts'
2import { type Timeline, counts } from './timeline.ts'
3
4const pad = (n: number) => String(n).padStart(2, '0')
5
6function ymd(d: Date): string {
7 return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate())
8}
9
10function hm(d: Date): string {
11 return pad(d.getHours()) + ':' + pad(d.getMinutes())
12}
13
14export function exportFileName(now: number): string {
15 const d = new Date(now)
16 return 'flight-recorder-' + ymd(d).replace(/-/g, '') + '-' + hm(d).replace(':', '') + '.md'
17}
18
19export function escapeCell(s: string): string {
20 return s.replace(/\|/g, '\\|').replace(/\s*\n\s*/g, ' ')
21}
22
23export function toMarkdown(t: Timeline, now: number): string {
24 const start = new Date(t.firstStepAt ?? now)
25 const c = counts(t, now)
26 const lines: string[] = [
27 '# Session log — ' + ymd(start) + ' ' + hm(start) + '–' + hm(new Date(now)),
28 '',
29 [plural(c.steps, 'step'), plural(c.edits, 'edit'), plural(c.fails, 'failure'), formatDuration(c.elapsedMs)].join(' · '),
30 '',
31 ]
32 if (t.dropped > 0) lines.push('_' + t.dropped + ' earlier steps not shown._', '')
33 lines.push('| Time | Step | Result | Duration |', '| --- | --- | --- | --- |')
34 for (const s of t.steps) {
35 const result = s.status === 'ok' ? 'ok' : s.status === 'fail' ? 'FAIL' : 'running'
36 const duration = s.durationMs === undefined ? '—' : formatDuration(s.durationMs)
37 lines.push('| ' + formatClock(s.startedAt) + ' | ' + escapeCell(s.tool + ' ' + s.label) + ' | ' + result + ' | ' + duration + ' |')
38 }
39
40 const files = new Map<string, number>()
41 for (const s of t.steps) if (s.kind === 'edit') files.set(s.label, (files.get(s.label) ?? 0) + 1)
42 lines.push('', '## Files changed', '')
43 if (files.size === 0) lines.push('- none')
44 for (const [label, n] of files) lines.push('- ' + label + ' (' + plural(n, 'edit') + ')')
45
46 const failures = t.steps.filter(s => s.status === 'fail')
47 lines.push('', '## Failures', '')
48 if (failures.length === 0) lines.push('- none')
49 for (const s of failures) {
50 lines.push('- ' + formatClock(s.startedAt) + ' `' + s.label + '`' + (s.error ? ' — ' + s.error : ''))
51 }
52 lines.push('')
53 return lines.join('\n')
54}
55src/labels.ts 47 lines1import { truncate } from './format.ts'
2import { relativeTo, resolvePath } from './paths.ts'
3import { redactSecrets, sanitize, stripControl } from './redact.ts'
4import type { StepInfo, StepKind } from './timeline.ts'
5
6export const LABEL_MAX = 60
7export const COMMAND_MAX = 2000
8
9export const ICONS: Record<StepKind, string> = { edit: '✎', bash: '$', read: '◎', other: '·' }
10
11const EDIT_TOOLS = new Set(['Edit', 'Write', 'NotebookEdit', 'MultiEdit'])
12const READ_TOOLS = new Set(['Read', 'Grep', 'Glob'])
13
14export function normalizeCommand(cmd: string): string {
15 return cmd.trim().replace(/\s+/g, ' ')
16}
17
18function str(input: Record<string, unknown>, key: string): string | undefined {
19 const v = input[key]
20 return typeof v === 'string' && v !== '' ? stripControl(v) : undefined
21}
22
23export function describeCall(
24 id: string,
25 tool: string,
26 input: Record<string, unknown>,
27 cwd: string,
28 root: string,
29): StepInfo {
30 if (EDIT_TOOLS.has(tool)) {
31 const raw = str(input, 'file_path') ?? str(input, 'notebook_path')
32 if (!raw) return { id, tool, kind: 'edit', label: tool }
33 const path = resolvePath(raw, cwd)
34 return { id, tool, kind: 'edit', label: truncate(redactSecrets(relativeTo(path, root)), LABEL_MAX), path }
35 }
36 if (tool === 'Bash') {
37 const command = normalizeCommand(str(input, 'command') ?? '').slice(0, COMMAND_MAX)
38 if (!command) return { id, tool, kind: 'bash', label: tool }
39 return { id, tool, kind: 'bash', label: truncate(redactSecrets(command), LABEL_MAX), command }
40 }
41 if (READ_TOOLS.has(tool)) {
42 const target = str(input, 'file_path') ?? str(input, 'pattern') ?? str(input, 'path') ?? tool
43 return { id, tool, kind: 'read', label: truncate(redactSecrets(relativeTo(target, root)), LABEL_MAX) }
44 }
45 return { id, tool, kind: 'other', label: truncate(sanitize(tool), LABEL_MAX) }
46}
47src/paths.ts 47 lines1const DRIVE = /^[A-Za-z]:/
2
3function toSlashes(p: string): string {
4 return p.replace(/\\/g, '/')
5}
6
7function isAbsolute(p: string): boolean {
8 return p.startsWith('/') || /^[A-Za-z]:(\/|$)/.test(p)
9}
10
11export function resolvePath(p: string, base: string): string {
12 const path = toSlashes(p)
13 const abs = isAbsolute(path) ? path : toSlashes(base).replace(/\/+$/, '') + '/' + path
14 const drive = abs.match(DRIVE)?.[0].toUpperCase() ?? ''
15 const out: string[] = []
16 for (const part of abs.slice(drive.length).split('/')) {
17 if (part === '' || part === '.') continue
18 if (part === '..') out.pop()
19 else out.push(part)
20 }
21 return drive + '/' + out.join('/')
22}
23
24// Resolved root without a trailing slash ('' for '/'), and whether to compare
25// case-insensitively (Windows drive paths).
26function rootKey(root: string): { key: string; fold: boolean } {
27 const r = resolvePath(root, '/')
28 return { key: r.endsWith('/') ? r.slice(0, -1) : r, fold: DRIVE.test(r) }
29}
30
31export function isInside(path: string, root: string): boolean {
32 const { key, fold } = rootKey(root)
33 if (key === '') return true
34 const p = resolvePath(path, '/')
35 const a = fold || DRIVE.test(p) ? p.toLowerCase() : p
36 const b = fold || DRIVE.test(p) ? key.toLowerCase() : key
37 return a === b || a.startsWith(b + '/')
38}
39
40export function relativeTo(path: string, root: string): string {
41 if (!isAbsolute(toSlashes(path)) || !isAbsolute(toSlashes(root))) return path
42 const { key } = rootKey(root)
43 if (key === '' || !isInside(path, root)) return path
44 const p = resolvePath(path, '/')
45 return p.length > key.length ? p.slice(key.length + 1) : path
46}
47src/render.ts 74 lines1import { type AlertConfig, isSlow } from './alerts.ts'
2import { formatClock, formatDuration, plural, truncate } from './format.ts'
3import { ICONS } from './labels.ts'
4import { type Step, type Timeline, counts, currentStep, lastFinished } from './timeline.ts'
5
6export type Color = 'green' | 'cyan' | 'red' | 'yellow'
7export type Tone = 'idle' | 'running' | 'fail' | 'slow'
8
9export interface Segment {
10 text: string
11 color?: Color
12}
13
14export interface PaneView {
15 header: string
16 earlier?: string
17 rows: Segment[]
18}
19
20const TONE_COLOR: Record<Tone, Color> = { idle: 'green', running: 'cyan', fail: 'red', slow: 'yellow' }
21
22export function bandTone(t: Timeline, now: number, cfg: AlertConfig): Tone {
23 const running = currentStep(t)
24 if (running) return isSlow(running, now, cfg) ? 'slow' : 'running'
25 return lastFinished(t)?.status === 'fail' ? 'fail' : 'idle'
26}
27
28export function summaryLine(t: Timeline, now: number): string {
29 const c = counts(t, now)
30 return [plural(c.steps, 'step'), plural(c.edits, 'edit'), plural(c.fails, 'fail'), formatDuration(c.elapsedMs)].join(' · ')
31}
32
33export function bandSegments(t: Timeline, now: number, cfg: AlertConfig, columns: number): Segment[] | null {
34 if (t.steps.length === 0 && t.dropped === 0) return null
35 let text = summaryLine(t, now)
36 const running = currentStep(t)
37 if (running) text += ' · now: ' + ICONS[running.kind] + ' ' + running.label + ' (' + formatDuration(now - running.startedAt) + ')'
38 return [
39 { text: '● ', color: TONE_COLOR[bandTone(t, now, cfg)] },
40 { text: truncate(text, columns - 2) },
41 ]
42}
43
44function stepRow(s: Step, now: number, columns: number): Segment {
45 const showTime = columns >= 40
46 const showDuration = columns >= 50
47 const mark = s.status === 'ok' ? '✓' : s.status === 'fail' ? '✗' : '…'
48 const head = (showTime ? formatClock(s.startedAt) + ' ' : '') + ICONS[s.kind] + ' '
49 const duration = s.durationMs ?? now - s.startedAt
50 const tail = (showDuration ? ' ' + formatDuration(duration) : '') + ' ' + mark
51 const labelWidth = Math.max(0, columns - head.length - tail.length)
52 const text = truncate(head + truncate(s.label, labelWidth).padEnd(labelWidth) + tail, Math.max(columns, 1))
53 if (s.status === 'fail') return { text, color: 'red' }
54 if (s.status === 'running') return { text, color: 'cyan' }
55 return { text }
56}
57
58export function paneView(t: Timeline, now: number, columns: number, maxRows: number): PaneView {
59 const fit = Math.max(1, maxRows)
60 const shown = t.steps.slice(-fit)
61 const hidden = t.dropped + (t.steps.length - shown.length)
62 return {
63 header: truncate(summaryLine(t, now), columns),
64 earlier: hidden > 0 ? '+' + hidden + ' earlier steps' : undefined,
65 rows: shown.map(s => stepRow(s, now, columns)),
66 }
67}
68
69export function summaryText(t: Timeline, now: number, last: number = 10): string {
70 if (t.steps.length === 0 && t.dropped === 0) return 'No steps recorded yet.'
71 const rows = t.steps.slice(-last).map(s => stepRow(s, now, 80).text.trimEnd())
72 return [summaryLine(t, now), ...rows].join('\n')
73}
74src/timeline.ts 100 lines1import { sanitize } from './redact.ts'
2
3export type StepStatus = 'running' | 'ok' | 'fail'
4export type StepKind = 'edit' | 'bash' | 'read' | 'other'
5
6export interface StepInfo {
7 id: string
8 tool: string
9 kind: StepKind
10 label: string
11 path?: string
12 command?: string
13}
14
15export interface Step extends StepInfo {
16 startedAt: number
17 durationMs?: number
18 status: StepStatus
19 error?: string
20}
21
22export interface Timeline {
23 steps: Step[]
24 dropped: number
25 firstStepAt?: number
26}
27
28export interface Counts {
29 steps: number
30 edits: number
31 fails: number
32 elapsedMs: number
33}
34
35export const MAX_STEPS = 2000
36
37export function createTimeline(): Timeline {
38 return { steps: [], dropped: 0 }
39}
40
41export function startStep(t: Timeline, info: StepInfo, now: number): Step {
42 const step: Step = { ...info, startedAt: now, status: 'running' }
43 if (t.firstStepAt === undefined) t.firstStepAt = now
44 t.steps.push(step)
45 const extra = t.steps.length - MAX_STEPS
46 if (extra > 0) {
47 t.steps.splice(0, extra)
48 t.dropped += extra
49 }
50 return step
51}
52
53export function finishStep(step: Step, status: 'ok' | 'fail', now: number, error?: string): void {
54 step.status = status
55 step.durationMs = Math.max(0, now - step.startedAt)
56 if (error) step.error = firstLine(error, 200)
57}
58
59function firstLine(text: string, max: number): string {
60 const line = text.split('\n').find(l => l.trim() !== '') ?? ''
61 return sanitize(line).trim().slice(0, max)
62}
63
64export function counts(t: Timeline, now: number): Counts {
65 let edits = 0
66 let fails = 0
67 for (const s of t.steps) {
68 if (s.kind === 'edit') edits++
69 if (s.status === 'fail') fails++
70 }
71 return {
72 steps: t.steps.length + t.dropped,
73 edits,
74 fails,
75 elapsedMs: t.firstStepAt === undefined ? 0 : Math.max(0, now - t.firstStepAt),
76 }
77}
78
79export function currentStep(t: Timeline): Step | undefined {
80 for (let i = t.steps.length - 1; i >= 0; i--) {
81 const s = t.steps[i]
82 if (s && s.status === 'running') return s
83 }
84 return undefined
85}
86
87export function lastFinished(t: Timeline): Step | undefined {
88 let best: Step | undefined
89 let bestEnd = -Infinity
90 for (const s of t.steps) {
91 if (s.status === 'running') continue
92 const end = s.startedAt + (s.durationMs ?? 0)
93 if (end >= bestEnd) {
94 best = s
95 bestEnd = end
96 }
97 }
98 return best
99}
100src/format.ts 26 lines1export function formatDuration(ms: number): string {
2 const safe = Math.max(0, ms)
3 if (safe < 10_000) return (Math.round(safe / 100) / 10).toFixed(1) + 's'
4 const totalSeconds = Math.floor(safe / 1000)
5 if (totalSeconds < 60) return totalSeconds + 's'
6 const minutes = Math.floor(totalSeconds / 60)
7 if (minutes < 60) return minutes + 'm' + String(totalSeconds % 60).padStart(2, '0') + 's'
8 return Math.floor(minutes / 60) + 'h' + String(minutes % 60).padStart(2, '0') + 'm'
9}
10
11export function formatClock(epochMs: number): string {
12 const d = new Date(epochMs)
13 return [d.getHours(), d.getMinutes(), d.getSeconds()].map(n => String(n).padStart(2, '0')).join(':')
14}
15
16export function truncate(text: string, width: number): string {
17 if (width <= 0) return ''
18 if (text.length <= width) return text
19 if (width === 1) return '…'
20 return text.slice(0, width - 1) + '…'
21}
22
23export function plural(n: number, word: string, many: string = word + 's'): string {
24 return n + ' ' + (n === 1 ? word : many)
25}
26src/redact.ts 29 lines1// Text from tool calls is untrusted: a command can carry terminal escape
2// sequences or credentials. Everything shown or exported passes through here.
3
4const WHITESPACE_CONTROLS = /[\t\n\r]/g
5const OTHER_CONTROLS = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f\u200e\u200f\u202a-\u202e\u2066-\u2069]/g
6
7export function stripControl(text: string): string {
8 return text.replace(WHITESPACE_CONTROLS, ' ').replace(OTHER_CONTROLS, '')
9}
10
11const REDACTED = '[redacted]'
12
13const URL_PASSWORD = /(\b[a-z][a-z0-9+.-]*:\/\/[^\s:@/]+:)[^\s@/]+@/gi
14const AUTH_HEADER = /(authorization:\s*(?:bearer|basic|token)\s+)[^\s"']+/gi
15const SECRET_ASSIGNMENT = /\b([A-Z0-9_]*(?:TOKEN|SECRET|PASSWORD|PASSWD|API_?KEY|ACCESS_KEY|PRIVATE_KEY)[A-Z0-9_]*=)("[^"]*"|'[^']*'|[^\s"']+)/gi
16const KNOWN_TOKENS = /\b(?:gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,}|sk-[A-Za-z0-9_-]{20,}|xox[baprs]-[A-Za-z0-9-]{10,}|AKIA[0-9A-Z]{16}|AIza[0-9A-Za-z_-]{35})/g
17
18export function redactSecrets(text: string): string {
19 return text
20 .replace(URL_PASSWORD, '$1' + REDACTED + '@')
21 .replace(AUTH_HEADER, '$1' + REDACTED)
22 .replace(SECRET_ASSIGNMENT, '$1' + REDACTED)
23 .replace(KNOWN_TOKENS, REDACTED)
24}
25
26export function sanitize(text: string): string {
27 return redactSecrets(stripControl(text))
28}
29