Agent History: exports every Claude Code session and its subagents to a folder, and commits and pushes it when the folder is a git repository

<img src="../assets/pane-light.png" alt="The agent-history panel beside the chat, after a turn: the session, the export folder, the git remote, the formats and the content, and the activity log with the export, the commit and the push" />
This folder is the Claude Code plugin of Agent History. It is a hooks module that Claude Code loads itself. After each turn, it exports the session and its subagents to a folder you choose, and commits and pushes it when the folder is a git repository.
/plugin marketplace add zirkelc/agent-plugins
/plugin install agent-history@zirkelc
Then open /config, search for agent-history, and set the export folder. The README describes the settings, the panel and the files.
hooks/register.tsx 137 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderInput } from 'claude-code'
3
4import { configOf } from './config'
5import type { Config } from './config'
6import type { Host } from './host'
7import { PADDING, paneView } from './pane-view'
8import { createSync } from './sync'
9import type { Sync } from './sync'
10
11/** Room kept for the engine's own end step inside the one short bound the end shares. */
12const END_MARGIN_MS = 250
13
14const status = atom({ plugin: 'agent-history', key: 'status' } as const, { mode: 'unset', pending: [] })
15const log = atom({ plugin: 'agent-history', key: 'log' } as const, [])
16
17const PANE_ID = 'agent-history'
18const PANE = { id: PANE_ID, title: 'agent-history', closeOnEscape: true } as const
19export const register: Register = (on, options) => {
20 const config = configOf(options)
21 /** Created at the session's start; a reload runs the start again and replaces it. */
22 let sync: Sync | undefined
23
24 on('session.start', async ($, e, next) => {
25 sync?.dispose()
26 sync = createSync(hostOf($), config)
27 await $.command.register({
28 name: 'agent-history',
29 description: 'Show the session export panel',
30 })
31 void sync.check()
32
33 return next(e)
34 })
35
36 /** The export runs on the sync's queue, so the turn ends without waiting for it. */
37 on('classic.Stop', ($, e, next) => {
38 void sync?.exportSession({ sessionId: e.session_id, transcriptPath: e.transcript_path, cwd: e.cwd })
39
40 return next(e)
41 })
42
43 on('classic.SubagentStop', ($, e, next) => {
44 void sync?.exportAgent({
45 sessionId: e.session_id,
46 agentId: e.agent_id,
47 agentType: e.agent_type,
48 transcriptPath: e.agent_transcript_path,
49 })
50
51 return next(e)
52 })
53
54 on('session.end', async ($, e, next) => {
55 const timeoutMs = Math.max(1_000, next.budget.remainingMs - END_MARGIN_MS)
56 await sync?.end(e.sessionId, timeoutMs)
57
58 return next(e)
59 })
60
61 /** Opening the panel reads the folder again, so it shows the repository as it is now, and marks the secrets as seen. */
62 on('command.run', { command: 'agent-history' }, async $ => {
63 void sync?.check()
64 void sync?.seenSecrets()
65 await $.ui.open(PANE)
66
67 return { text: 'agent-history panel opened.' }
68 })
69
70 /** The view uses elements the terminal and the desktop both have; elsewhere the engine draws its own. */
71 on('ui.render', { component: 'Pane', requestId: PANE_ID }, ($, e, next) =>
72 e.surface === 'terminal' || e.surface === 'desktop' ? renderPane($, e, config, () => sync) : next(e),
73 )
74}
75
76/** The engine as the export and git code sees it, bound to the session's `$`. */
77function hostOf($: EngineInterface): Host {
78 return {
79 run: (argv, init) => $.process.run(argv, init),
80 runText: async argv => {
81 const parts: Array<string> = []
82 for await (const chunk of $.process.spawn({ argv })) {
83 if (chunk.stream === 'stdout') parts.push(chunk.text)
84 }
85 return parts.join('')
86 },
87 read: async path => (await $.fs.read(path)) as string,
88 write: (path, text) => $.fs.write(path, text),
89 exists: path => $.fs.exists(path),
90 size: async path => (await $.fs.stat(path)).size,
91 mtime: async path => (await $.fs.stat(path)).mtimeMs,
92 home: () => $.env.get('HOME'),
93 now: () => $.clock.now(),
94 sleep: ms => $.clock.sleep(ms),
95 after: (ms, fn) => $.clock.after(ms, fn),
96 status: text => $.ui.status(text),
97 readStatus: () => read($, status),
98 updateStatus: change => update($, status, change),
99 updateLog: change => update($, log, change),
100 }
101}
102
103async function renderPane(
104 $: EngineInterface,
105 e: RenderInput<'Pane'>,
106 config: Config,
107 syncOf: () => Sync | undefined,
108) {
109 const { Box, Text, Button } = $.ui.resolve(e)
110 const [value, entries, sessionId, home] = await Promise.all([
111 read($, status),
112 read($, log),
113 $.session.id(),
114 $.env.get('HOME'),
115 ])
116
117 return paneView(
118 { Box, Text, Button },
119 {
120 syncNow: () => void syncOf()?.syncNow(),
121 initRepo: () => void syncOf()?.initRepo(),
122 close: () => void $.ui.close({ id: PANE_ID }),
123 },
124 {
125 status: value,
126 log: entries,
127 sessionId,
128 home: home ?? '',
129 config,
130 columns: Math.max(e.props.bodyColumns - 2 * PADDING - 1, 20),
131 bodyRows: e.props.scroll.bodyRows,
132 isDocked: e.props.placement === 'dock',
133 surface: e.surface,
134 },
135 )
136}
137hooks/config.ts 50 lines1import type { PluginOptions } from 'claude-code'
2
3import type { Format } from './meta'
4import type { Filters } from './transcript'
5
6export type Config = {
7 /** As the person wrote it; `~` is expanded once the home directory is known. */
8 exportDir: string
9 formats: Array<Format>
10 filters: Filters
11 /** Whether secrets in the exported files are replaced with a marker. */
12 redactSecrets: boolean
13 autoPush: boolean
14 commitDelayMs: number
15}
16
17const DEFAULT_COMMIT_DELAY_SECONDS = 60
18
19/** The options are untyped at run time, so each value is checked and falls back to its manifest default. */
20export function configOf(options: PluginOptions): Config {
21 const flag = (key: string, fallback: boolean) => (typeof options[key] === 'boolean' ? (options[key] as boolean) : fallback)
22 const seconds = typeof options.commitDelaySeconds === 'number' ? options.commitDelaySeconds : DEFAULT_COMMIT_DELAY_SECONDS
23
24 const formats: Array<Format> = []
25 if (flag('exportJsonl', true)) formats.push('jsonl')
26 if (flag('exportMarkdown', true)) formats.push('markdown')
27
28 return {
29 exportDir: typeof options.exportDir === 'string' ? options.exportDir.trim() : '',
30 formats,
31 filters: {
32 text: flag('includeText', true),
33 toolUse: flag('includeToolUse', true),
34 toolInput: flag('includeToolInput', false),
35 toolResults: flag('includeToolResults', false),
36 thinking: flag('includeThinking', false),
37 meta: flag('includeMeta', false),
38 },
39 redactSecrets: flag('redactSecrets', true),
40 autoPush: flag('autoPush', true),
41 commitDelayMs: Math.max(0, seconds) * 1_000,
42 }
43}
44
45export function expandHome(path: string, home: string | undefined): string {
46 if (!home) return path
47 if (path === '~') return home
48 return path.startsWith('~/') ? `${home}${path.slice(1)}` : path
49}
50hooks/host.ts 30 lines1import type { ProcessRunInit, ProcessRunResult, Timer } from 'claude-code'
2
3import type { AgentHistoryLogEntry, AgentHistoryStatus } from '../types'
4
5/**
6 * What the export and git code needs from the engine. The hooks module builds
7 * it from `$`, which the engine reads from that file's source alone; every
8 * other module works against this shape and never sees `$`.
9 */
10export type Host = {
11 run: (argv: ReadonlyArray<string>, init?: ProcessRunInit) => Promise<ProcessRunResult>
12 /** A child's whole standard output, streamed, so no size limit applies. */
13 runText: (argv: ReadonlyArray<string>) => Promise<string>
14 read: (path: string) => Promise<string>
15 write: (path: string, text: string) => Promise<void>
16 exists: (path: string) => Promise<boolean>
17 size: (path: string) => Promise<number>
18 /** Last modification, milliseconds since the epoch. */
19 mtime: (path: string) => Promise<number>
20 /** The home directory, for an export folder written with `~`. */
21 home: () => Promise<string | undefined>
22 now: () => Promise<number>
23 sleep: (ms: number) => Promise<void>
24 after: (ms: number, fn: () => void) => Timer
25 status: (text: string | undefined) => void
26 readStatus: () => Promise<AgentHistoryStatus>
27 updateStatus: (change: (value: AgentHistoryStatus) => AgentHistoryStatus) => Promise<AgentHistoryStatus>
28 updateLog: (change: (list: Array<AgentHistoryLogEntry>) => Array<AgentHistoryLogEntry>) => Promise<unknown>
29}
30hooks/pane-view.tsx 315 lines1import type { ElementTable, RenderElement } from 'claude-code'
2
3import type { AgentHistoryLogEntry, AgentHistoryStatus } from '../types'
4import type { Config } from './config'
5import { clockTime, secretsText } from './sync'
6import type { Filters } from './transcript'
7
8/** The elements the pane draws with, on the surfaces that have all of them. */
9export type Ui = Pick<ElementTable<'terminal' | 'desktop'>, 'Box' | 'Text' | 'Button'>
10
11/** What a press in the pane does. The view only calls these. */
12export type PaneActions = {
13 syncNow: () => void
14 initRepo: () => void
15 close: () => void
16}
17
18/** Everything one drawing of the pane reads. */
19export type PaneModel = {
20 status: AgentHistoryStatus
21 log: ReadonlyArray<AgentHistoryLogEntry>
22 /** The session the log belongs to; each session has its own log. */
23 sessionId: string
24 /** The home folder, shown as `~` at the start of a path. */
25 home: string
26 config: Config
27 /** The columns inside the pane's padding. */
28 columns: number
29 /** The rows the pane's body has room for. */
30 bodyRows: number
31 /** Whether the pane is docked, with rows of its own to fill, rather than inline, where the frame fits the tree. */
32 isDocked: boolean
33 /** The terminal gets colored buttons of its own, the other surfaces their native ones. */
34 surface: string
35}
36
37/** The space between the pane's frame and its content, on the left and on the right. */
38export const PADDING = 1
39
40/** The background of an action button in the terminal: the blue of a VS Code button, readable in both themes. */
41const BUTTON_COLOR = '#0e639c'
42
43/** The mark of a folder kept in git. */
44const SYNC_ICON = '⇅'
45
46/** The columns a row's label takes, so the values of every row start in one column. */
47const LABEL_WIDTH = 'Content'.length
48
49/** The rows around the log: header and margin, six info rows and the hint, two rules, the log's title, the status line. */
50const OUTER_ROWS = 2 + 7 + 2 + 1 + 1
51
52/** The width of the step column in the log. */
53const STEP_WIDTH = 'commit'.length
54
55/** The most rows the foot gives a warning; a longer one is cut. */
56const WARNING_ROWS = 3
57
58/** The fewest log rows shown, however small the pane. */
59const MIN_LOG_ROWS = 3
60
61const FILTER_LABELS: ReadonlyArray<[keyof Filters, string]> = [
62 ['text', 'text'],
63 ['toolUse', 'tool calls'],
64 ['toolInput', 'tool input'],
65 ['toolResults', 'tool results'],
66 ['thinking', 'thinking'],
67 ['meta', 'meta'],
68]
69
70const FORMAT_LABELS: Record<Config['formats'][number], string> = { jsonl: 'JSONL', markdown: 'Markdown' }
71
72type ButtonLook = 'primary' | 'neutral'
73
74/** In the terminal a label on a colored ground or as plain text; elsewhere the surface's own button. */
75function actionButton(
76 ui: Ui,
77 model: PaneModel,
78 key: string,
79 label: string,
80 onPress: () => void,
81 look: ButtonLook = 'primary',
82): RenderElement {
83 const { Box, Button } = ui
84 if (model.surface !== 'terminal') {
85 return (
86 <Button key={key} variant={look === 'primary' ? 'primary' : 'secondary'} onPress={onPress}>
87 {label}
88 </Button>
89 )
90 }
91 return (
92 <Box key={`box:${key}`} paddingX={1} {...(look === 'primary' ? { backgroundColor: BUTTON_COLOR } : {})}>
93 <Button key={key} plain onPress={onPress}>
94 {label}
95 </Button>
96 </Box>
97 )
98}
99
100function ruleView(ui: Ui, model: PaneModel, key: string): RenderElement {
101 const { Text } = ui
102 return (
103 <Text key={key} dimColor>
104 {'─'.repeat(model.columns)}
105 </Text>
106 )
107}
108
109/** One info row: a dim label padded to the labels' width, then its value. */
110function row(ui: Ui, key: string, label: string, value: RenderElement | string): RenderElement {
111 const { Box, Text } = ui
112 return (
113 <Box key={key} flexDirection="row" columnGap={1}>
114 <Text dimColor>{label.padEnd(LABEL_WIDTH, ' ')}</Text>
115 {typeof value === 'string' ? <Text wrap="truncate">{value}</Text> : value}
116 </Box>
117 )
118}
119
120/** A path cut from the front to a width, so its last parts, which tell it apart, stay. */
121export function pathFit(path: string, width: number): string {
122 const chars = [...path]
123 if (chars.length <= width) return path
124 return width <= 1 ? '…' : `…${chars.slice(chars.length - width + 1).join('')}`
125}
126
127/** A path with the home folder at its start written as `~`. */
128export function homeFit(path: string, home: string): string {
129 const base = home.replace(/\/+$/, '')
130 if (!base) return path
131 if (path === base) return '~'
132 return path.startsWith(`${base}/`) ? `~${path.slice(base.length)}` : path
133}
134
135/** A text cut at the end to a width. */
136export function fit(text: string, width: number): string {
137 const chars = [...text.replace(/\s+/g, ' ')]
138 return chars.length <= width ? chars.join('') : `${chars.slice(0, Math.max(width - 1, 0)).join('')}…`
139}
140
141function headerView(ui: Ui, actions: PaneActions, model: PaneModel): RenderElement {
142 const { Box, Text } = ui
143 const repo = model.status.repo
144 return (
145 <Box flexDirection="row" justifyContent="space-between" marginBottom={1}>
146 <Text bold color="cyan">
147 AGENT HISTORY
148 </Text>
149 <Box flexDirection="row" columnGap={2}>
150 {repo?.isRepo && actionButton(ui, model, 'sync', `${SYNC_ICON} Sync now`, actions.syncNow)}
151 {repo && !repo.isRepo && actionButton(ui, model, 'init', '+ Init git repo', actions.initRepo)}
152 {actionButton(ui, model, 'close', '✕ Close', actions.close, 'neutral')}
153 </Box>
154 </Box>
155 )
156}
157
158/** What git does with the folder: its remote in green, a missing remote in yellow, no repository dim. */
159function gitValue(ui: Ui, model: PaneModel): RenderElement {
160 const { Text } = ui
161 const { mode, repo } = model.status
162 if (!repo) return <Text dimColor>-</Text>
163 if (!repo.isRepo) return <Text dimColor>not a repository: export only</Text>
164 if (!repo.remote) return <Text color="yellow">{`${SYNC_ICON} ${repo.branch ?? ''}, no remote: commit only`}</Text>
165 const target = `${repo.remote}/${repo.branch ?? ''}`
166 return mode === 'push' ? (
167 <Text color="green">{`${SYNC_ICON} ${target}`}</Text>
168 ) : (
169 <Text dimColor>{`${SYNC_ICON} ${target}, push off: commit only`}</Text>
170 )
171}
172
173function infoView(ui: Ui, model: PaneModel): Array<RenderElement> {
174 const { Text } = ui
175 const { config, status } = model
176 const folder = status.repo?.exportDir ?? config.exportDir
177 const formats = config.formats.map(format => FORMAT_LABELS[format]).join(' · ') || 'meta only'
178 const content = FILTER_LABELS.filter(([key]) => config.filters[key])
179 .map(([, label]) => label)
180 .join(' · ')
181 return [
182 row(ui, 'session', 'Session', fit(model.sessionId, model.columns - LABEL_WIDTH - 1)),
183 row(
184 ui,
185 'folder',
186 'Folder',
187 folder ? pathFit(homeFit(folder, model.home), model.columns - LABEL_WIDTH - 1) : <Text color="yellow">not set: /config</Text>,
188 ),
189 row(ui, 'git', 'Git', gitValue(ui, model)),
190 row(ui, 'formats', 'Formats', formats),
191 row(ui, 'content', 'Content', fit(content || 'nothing', model.columns - LABEL_WIDTH - 1)),
192 row(
193 ui,
194 'secrets',
195 'Secrets',
196 config.redactSecrets ? 'redacted' : <Text color="yellow">kept: exported as they are</Text>,
197 ),
198 <Text key="hint" dimColor italic wrap="truncate">
199 {fit('Change the settings in /config', model.columns)}
200 </Text>,
201 ]
202}
203
204/** One log line, always one row so the rows below never move: the dim time, a green, yellow or red dot, the step, then what it did. */
205function logRow(ui: Ui, model: PaneModel, entry: AgentHistoryLogEntry, at: number): RenderElement {
206 const { Box, Text } = ui
207 const room = model.columns - 5 - 1 - 1 - 1 - STEP_WIDTH - 1
208 const color = entry.isOk ? undefined : entry.step === 'secret' ? 'yellow' : 'red'
209 return (
210 <Box key={`log:${at}`} flexDirection="row" columnGap={1}>
211 <Text dimColor>{clockTime(entry.at)}</Text>
212 <Text color={color ?? 'green'}>⏺</Text>
213 <Text bold={!entry.isOk}>{entry.step.padEnd(STEP_WIDTH, ' ')}</Text>
214 <Text dimColor={entry.isOk} color={color} wrap="truncate">
215 {fit(entry.message, room)}
216 </Text>
217 </Box>
218 )
219}
220
221function logView(ui: Ui, model: PaneModel): Array<RenderElement> {
222 const { Text } = ui
223 const rows = Math.max(model.bodyRows - OUTER_ROWS - (footRowsOf(model) - 1), MIN_LOG_ROWS)
224 const shown = model.log.slice(-rows).reverse()
225 const lines =
226 shown.length === 0
227 ? [
228 <Text key="empty" dimColor>
229 Nothing exported in this session yet. The next turn exports it.
230 </Text>,
231 ]
232 : shown.map((entry, at) => logRow(ui, model, entry, at))
233 /** A docked pane fills its rows, so the status line stays at its foot. */
234 const pad = model.isDocked
235 ? Array.from({ length: Math.max(rows - lines.length, 0) }, (_, at) => <Text key={`pad:${at}`}> </Text>)
236 : []
237 return [...lines, ...pad]
238}
239
240/** The warning with the message of the step that failed, which says what to fix; else the secrets found. */
241function warningText(model: PaneModel): string | undefined {
242 const { warning, secrets } = model.status
243 if (!warning) return secrets ? `⚠ ${secretsText(secrets)}` : undefined
244 const failed = [...model.log].reverse().find(entry => !entry.isOk)
245 const detail = failed && warning.startsWith(failed.step) ? `: ${failed.message}` : ''
246 return `⚠ ${warning}${detail}`
247}
248
249/** A text cut into rows of a width, at most `count` of them, the last ending in `…` when more is left. */
250export function rowsOf(text: string, width: number, count: number): Array<string> {
251 const chars = [...text.replace(/\s+/g, ' ')]
252 const rows: Array<string> = []
253 for (let at = 0; at < chars.length && rows.length < count; at += width) rows.push(chars.slice(at, at + width).join(''))
254 if (chars.length > width * count) rows[count - 1] = [...(rows[count - 1] ?? '')].slice(0, width - 1).join('') + '…'
255 return rows
256}
257
258/** The foot's rows: as many as the warning needs, up to a few; the log gives up the same rows. */
259function footRowsOf(model: PaneModel): number {
260 const warning = warningText(model)
261 return warning ? Math.max(rowsOf(warning, model.columns, WARNING_ROWS).length, 1) : 1
262}
263
264/** The foot: the warning or the secrets in full while one stands, else when each step last ran and what waits for the next commit, its mark at the end of the rule above. */
265function statusView(ui: Ui, model: PaneModel): RenderElement {
266 const { Box, Text } = ui
267 const { status } = model
268 const warning = warningText(model)
269 if (warning) {
270 const isMild = !status.warning || status.warning === 'no remote' || status.mode === 'unset'
271 const color = isMild ? 'yellow' : 'red'
272 const rows = rowsOf(warning, model.columns, WARNING_ROWS)
273 return (
274 <Box key="status" flexDirection="column">
275 {rows.map((text, at) => (
276 <Text key={`warning:${at}`} color={color} wrap="truncate">
277 {text}
278 </Text>
279 ))}
280 </Box>
281 )
282 }
283 const time = (ms: number | undefined) => (ms === undefined ? '-' : clockTime(ms))
284 const parts = [
285 `export ${time(status.lastExportAt)}`,
286 ...(status.repo?.isRepo ? [`commit ${status.lastCommit ? `${status.lastCommit.sha} ${clockTime(status.lastCommit.at)}` : '-'}`] : []),
287 ...(status.mode === 'push' ? [`push ${time(status.lastPushAt)}`] : []),
288 ...(status.pending.length > 0 ? [`${status.pending.length} pending`] : []),
289 ]
290 return (
291 <Box key="status" flexDirection="row" justifyContent="space-between" width={model.columns}>
292 <Text dimColor wrap="truncate">
293 {fit(parts.join(' · '), model.columns - 2)}
294 </Text>
295 <Text color="green">✓</Text>
296 </Box>
297 )
298}
299
300/** The whole pane: the header and its actions, what the export does, the activity log, the status line. */
301export function paneView(ui: Ui, actions: PaneActions, model: PaneModel): RenderElement {
302 const { Box, Text } = ui
303 return (
304 <Box flexDirection="column" paddingX={PADDING}>
305 {headerView(ui, actions, model)}
306 {infoView(ui, model)}
307 {ruleView(ui, model, 'rule:log')}
308 <Text bold>Activity in this session</Text>
309 {logView(ui, model)}
310 {ruleView(ui, model, 'rule:status')}
311 {statusView(ui, model)}
312 </Box>
313 )
314}
315hooks/sync.ts 271 lines1import type { Timer } from 'claude-code'
2
3import type { AgentHistoryLogEntry, AgentHistoryMode, AgentHistorySecrets, AgentHistoryStatus } from '../types'
4import { expandHome } from './config'
5import type { Config } from './config'
6import { SESSIONS_DIR, exportAgent, exportSession } from './exporter'
7import type { AgentSource, SessionSource } from './exporter'
8import { commit, initRepo, push, repair, repoState } from './git'
9import type { Host } from './host'
10import type { RepoState } from './git'
11import type { Redaction } from './redact'
12
13const MAX_LOG = 50
14
15export type Sync = ReturnType<typeof createSync>
16
17/**
18 * The export and git work of one session. Every step runs on one queue, so
19 * exports and commits never overlap within the session; other sessions that
20 * share the export folder are covered by the retries of the git helpers.
21 */
22export function createSync(host: Host, config: Config) {
23 let exportDir = ''
24 let repo: RepoState = { isRepo: false }
25 let queue: Promise<unknown> = Promise.resolve()
26 let timer: Timer | undefined
27 /** The last transcript seen per session, exported once more when the session ends. */
28 const sources = new Map<string, SessionSource>()
29 /** The secrets found by kind in the last export of each transcript: the session's under its id, an agent's under its agent id. */
30 const secrets = new Map<string, Redaction['kinds']>()
31
32 function enqueue<T>(task: () => Promise<T>): Promise<T | undefined> {
33 const run = queue.then(task).catch(() => undefined)
34 queue = run
35 return run
36 }
37
38 async function record(step: AgentHistoryLogEntry['step'], isOk: boolean, message: string) {
39 const entry: AgentHistoryLogEntry = { at: await host.now(), step, isOk, message }
40 await host.updateLog(list => [...list, entry].slice(-MAX_LOG))
41 }
42
43 async function setStatus(change: (value: AgentHistoryStatus) => AgentHistoryStatus) {
44 const next = await host.updateStatus(change)
45 host.status(statusLine(next))
46 }
47
48 async function fail(step: AgentHistoryLogEntry['step'], error: unknown) {
49 /** The engine names the mod before its own errors; the panel already says whose they are. */
50 const message = (error instanceof Error ? error.message : String(error)).replace(/^agent-history: /, '')
51 await record(step, false, message)
52 await setStatus(value => ({ ...value, warning: `${step} failed` }))
53 }
54
55 /** Reads the export folder and the repository; changes nothing on disk. */
56 async function check() {
57 const home = await host.home()
58 exportDir = expandHome(config.exportDir, home).replace(/\/+$/, '')
59
60 if (!exportDir) {
61 repo = { isRepo: false }
62 await setStatus(value => ({ ...value, mode: 'unset', repo: undefined, warning: 'set the export folder in /config' }))
63 return
64 }
65
66 try {
67 repo = await repoState(host, exportDir)
68 } catch (error) {
69 repo = { isRepo: false }
70 await fail('check', error)
71 return
72 }
73
74 const mode: AgentHistoryMode = !repo.isRepo ? 'export-only' : repo.remote && config.autoPush ? 'push' : 'commit'
75 const warning = repo.isRepo && !repo.remote ? 'no remote' : undefined
76 await setStatus(value => ({ ...value, mode, repo: { exportDir, ...repo }, warning }))
77 }
78
79 function schedule() {
80 if (!repo.isRepo) return
81 timer?.cancel()
82 timer = host.after(config.commitDelayMs, () => {
83 timer = undefined
84 void enqueue(commitAndPush)
85 })
86 }
87
88 async function exported(sessionId: string, message: string) {
89 const at = await host.now()
90 await record('export', true, message)
91 await setStatus(value => ({
92 ...value,
93 lastExportAt: at,
94 pending: value.pending.includes(sessionId) ? value.pending : [...value.pending, sessionId],
95 warning: value.warning === 'export failed' ? undefined : value.warning,
96 }))
97 schedule()
98 }
99
100 /**
101 * Each export holds the whole transcript, so only kinds whose count went up
102 * are new. A new secret gets one log entry and shows the warning again.
103 */
104 async function found(key: string, kinds: Redaction['kinds']) {
105 const before = secrets.get(key) ?? {}
106 secrets.set(key, kinds)
107 const added = Object.entries(kinds)
108 .map(([kind, count]) => [kind, count - (before[kind] ?? 0)] as const)
109 .filter(([, count]) => count > 0)
110 if (added.length === 0) return
111
112 const count = added.reduce((sum, [, n]) => sum + n, 0)
113 const what = config.redactSecrets ? `secret${plural(count)} redacted` : `possible secret${plural(count)} exported`
114 await record('secret', false, `${count} new ${what} (${added.map(([kind]) => kind).join(', ')})`)
115 const total = [...secrets.values()].reduce((sum, each) => sum + Object.values(each).reduce((a, n) => a + n, 0), 0)
116 await setStatus(value => ({ ...value, secrets: { count: total, isRedacted: config.redactSecrets, isSeen: false } }))
117 }
118
119 async function commitAndPush(timeoutMs?: number) {
120 if (!repo.isRepo) return
121
122 const { pending } = await host.readStatus()
123 const message = pending.length > 0 ? `sync: ${pending.join(', ')}` : 'sync'
124
125 /** A git process killed in an earlier sync, of this session or another, can leave the repository so that no commit works. */
126 try {
127 const repaired = await repair(host, exportDir, [SESSIONS_DIR])
128 if (repaired) await record('repair', true, repaired)
129 } catch (error) {
130 return fail('repair', error)
131 }
132
133 let sha: string | undefined
134 try {
135 sha = await commit(host, exportDir, [SESSIONS_DIR], message)
136 } catch (error) {
137 return fail('commit', error)
138 }
139
140 const at = await host.now()
141 if (sha) await record('commit', true, `${sha} ${message}`)
142 await setStatus(value => ({
143 ...value,
144 pending: value.pending.filter(id => !pending.includes(id)),
145 lastCommit: sha ? { sha, at } : value.lastCommit,
146 warning: value.warning === 'commit failed' || value.warning === 'repair failed' ? undefined : value.warning,
147 }))
148
149 if (!config.autoPush || !repo.remote) return
150 try {
151 await push(host, exportDir, repo, timeoutMs)
152 } catch (error) {
153 return fail('push', error)
154 }
155 await record('push', true, `${repo.remote}/${repo.branch ?? ''}`)
156 await setStatus(value => ({
157 ...value,
158 lastPushAt: at,
159 warning: value.warning === 'push failed' ? undefined : value.warning,
160 }))
161 }
162
163 return {
164 check: () => enqueue(check),
165
166 exportSession(source: SessionSource) {
167 sources.set(source.sessionId, source)
168 return enqueue(async () => {
169 if (!exportDir) return
170 let kinds: Redaction['kinds']
171 try {
172 kinds = await exportSession(host, config, exportDir, source)
173 } catch (error) {
174 return fail('export', error)
175 }
176 await exported(source.sessionId, `session ${source.sessionId}`)
177 await found(source.sessionId, kinds)
178 })
179 },
180
181 exportAgent(source: AgentSource) {
182 return enqueue(async () => {
183 if (!exportDir) return
184 let kinds: Redaction['kinds']
185 try {
186 const result = await exportAgent(host, config, exportDir, source)
187 if (result === false) return
188 kinds = result
189 } catch (error) {
190 return fail('export', error)
191 }
192 await exported(source.sessionId, `agent ${source.agentType} ${source.agentId}`)
193 await found(`${source.sessionId}/${source.agentId}`, kinds)
194 })
195 },
196
197 /** Reads the repository again, since a remote can be added at any time, then commits and pushes without waiting for the timer. */
198 syncNow(timeoutMs?: number) {
199 timer?.cancel()
200 timer = undefined
201 return enqueue(async () => {
202 await check()
203 await commitAndPush(timeoutMs)
204 })
205 },
206
207 /** Exports the session one last time, then commits and pushes within the time the end leaves. */
208 async end(sessionId: string, timeoutMs: number) {
209 const source = sources.get(sessionId)
210 if (source) await this.exportSession(source)
211 timer?.cancel()
212 timer = undefined
213 /** No new check of the repository here: the end's time is short, and the start's check stands. */
214 await enqueue(() => commitAndPush(timeoutMs))
215 },
216
217 /** Turns the export folder into a repository; only ever on the person's request. */
218 initRepo() {
219 return enqueue(async () => {
220 if (!exportDir) return
221 try {
222 await initRepo(host, exportDir)
223 } catch (error) {
224 return fail('init', error)
225 }
226 await record('init', true, exportDir)
227 await check()
228 })
229 },
230
231 /** The panel shows the secrets, so the status line stops to show them until a new one is found. */
232 seenSecrets() {
233 return enqueue(() =>
234 setStatus(value => (value.secrets ? { ...value, secrets: { ...value.secrets, isSeen: true } } : value)),
235 )
236 },
237
238 dispose() {
239 timer?.cancel()
240 timer = undefined
241 },
242 }
243}
244
245/**
246 * The status line: the warning while one stands, else the secrets not yet
247 * seen in the panel, otherwise none. Claude Code draws every mod's status line
248 * as a warning with the mod's name in front, so a line for a good state would
249 * read as a problem.
250 */
251export function statusLine(value: AgentHistoryStatus): string | undefined {
252 return value.warning ?? (value.secrets && !value.secrets.isSeen ? secretsText(value.secrets) : undefined)
253}
254
255export function secretsText(secrets: AgentHistorySecrets): string {
256 const { count } = secrets
257 return secrets.isRedacted
258 ? `${count} secret${plural(count)} redacted in this session`
259 : `${count} possible secret${plural(count)} exported in this session (redaction is off)`
260}
261
262function plural(count: number): string {
263 return count === 1 ? '' : 's'
264}
265
266export function clockTime(ms: number): string {
267 const date = new Date(ms)
268 const pad = (n: number) => String(n).padStart(2, '0')
269 return `${pad(date.getHours())}:${pad(date.getMinutes())}`
270}
271hooks/meta.ts 88 lines1import { isMetaLine } from './transcript'
2import type { Filters, Line } from './transcript'
3
4export type Format = 'jsonl' | 'markdown'
5
6export type GitInfo = {
7 repo: string
8 branch?: string
9 worktree: string
10}
11
12export type AgentMeta = {
13 agentId: string
14 agentType: string
15 startedAt?: string
16 updatedAt?: string
17 turns: number
18}
19
20export type SessionMeta = {
21 sessionId: string
22 cwd?: string
23 git: GitInfo | null
24 startedAt?: string
25 updatedAt?: string
26 turns: number
27 agents: Array<AgentMeta>
28 export: {
29 formats: Array<Format>
30 filters: Filters
31 redactSecrets?: boolean
32 exportedAt: string
33 }
34}
35
36/** What the transcript itself says about its session, read from the whole, unfiltered lines. */
37export type TranscriptFacts = {
38 cwd?: string
39 gitBranch?: string
40 startedAt?: string
41 updatedAt?: string
42 turns: number
43}
44
45export function factsOf(lines: Array<Line>): TranscriptFacts {
46 const stamped = lines.filter(line => typeof line.timestamp === 'string')
47 const cwd = lines.find(line => typeof line.cwd === 'string')?.cwd
48 const gitBranch = [...lines].reverse().find(line => typeof line.gitBranch === 'string' && line.gitBranch)?.gitBranch
49
50 return {
51 cwd,
52 gitBranch,
53 startedAt: stamped[0]?.timestamp,
54 updatedAt: stamped[stamped.length - 1]?.timestamp,
55 turns: lines.filter(isPromptLine).length,
56 }
57}
58
59/** A turn starts with a prompt the person typed: a user message that is neither meta nor only tool results. */
60function isPromptLine(line: Line): boolean {
61 if (line.type !== 'user' || isMetaLine(line)) return false
62 const content = line.message?.content
63 if (typeof content === 'string') return content.trim().length > 0
64 return Array.isArray(content) && content.some(item => item.type === 'text')
65}
66
67/** Keeps the agents an earlier export recorded and puts this one in its place, ordered by start. */
68export function withAgent(agents: Array<AgentMeta>, agent: AgentMeta): Array<AgentMeta> {
69 return [...agents.filter(one => one.agentId !== agent.agentId), agent].sort((a, b) =>
70 (a.startedAt ?? '').localeCompare(b.startedAt ?? ''),
71 )
72}
73
74/** The meta of an earlier export, or undefined when there is none or it does not parse. */
75export function parseMeta(text: string | undefined): SessionMeta | undefined {
76 if (text === undefined) return undefined
77 try {
78 const meta = JSON.parse(text) as SessionMeta
79 return typeof meta.sessionId === 'string' ? meta : undefined
80 } catch {
81 return undefined
82 }
83}
84
85export function serializeMeta(meta: SessionMeta): string {
86 return JSON.stringify(meta, null, 2) + '\n'
87}
88hooks/transcript.ts 149 lines1/**
2 * Parses a Claude Code transcript and reduces it to the content types the
3 * person chose to keep.
4 *
5 * Filtering is idempotent: a filtered transcript filters to itself under the
6 * same filters, so an exported file can pass through it again.
7 */
8
9export type ContentItem =
10 | { type: 'text'; text: string }
11 | { type: 'thinking'; thinking: string; signature?: string }
12 | { type: 'tool_use'; id: string; name: string; input?: unknown }
13 | { type: 'tool_result'; tool_use_id: string; content?: string | Array<ContentItem>; is_error?: boolean }
14 | { type: 'image'; source?: unknown }
15 | { type: string; [key: string]: unknown }
16
17export type Line = {
18 type: string
19 uuid?: string
20 timestamp?: string
21 cwd?: string
22 gitBranch?: string
23 isMeta?: boolean
24 isCompactSummary?: boolean
25 message?: { role?: string; model?: string; content?: string | Array<ContentItem> }
26 [key: string]: unknown
27}
28
29export type Filters = {
30 text: boolean
31 toolUse: boolean
32 toolInput: boolean
33 toolResults: boolean
34 thinking: boolean
35 meta: boolean
36}
37
38/** Marks assistant messages that Claude Code generated itself, such as API errors. */
39const SYNTHETIC_MODEL = '<synthetic>'
40
41/** Lines that do not parse are skipped: one torn line must not cost the rest of the session. */
42export function parseTranscript(raw: string): Array<Line> {
43 const lines: Array<Line> = []
44 for (const text of raw.split('\n')) {
45 if (!text.trim()) continue
46 try {
47 lines.push(JSON.parse(text) as Line)
48 } catch {
49 /** A partial last line while the session still writes. */
50 }
51 }
52 return lines
53}
54
55export function serializeTranscript(lines: Array<Line>): string {
56 return lines.map(line => JSON.stringify(line) + '\n').join('')
57}
58
59/** With every filter on, the transcript is kept whole and can be copied as it is. */
60export function keepsEverything(filters: Filters): boolean {
61 return Object.values(filters).every(Boolean)
62}
63
64/**
65 * Whether a line is one Claude Code added itself rather than a message the
66 * person or the model wrote: reminders, attachments, system rows, summaries
67 * and synthetic assistant messages.
68 */
69export function isMetaLine(line: Line): boolean {
70 if (line.type !== 'user' && line.type !== 'assistant') return true
71 if (line.isMeta === true) return true
72 return line.message?.model === SYNTHETIC_MODEL
73}
74
75/**
76 * Keeps the lines and content blocks the filters allow. User and assistant
77 * messages are reduced to a fixed set of fields; a meta line, when kept, is
78 * kept whole. A message left with no content is dropped, which is what
79 * becomes of a user message that only carried tool results when those are off.
80 */
81export function filterTranscript(lines: Array<Line>, filters: Filters): Array<Line> {
82 const out: Array<Line> = []
83 for (const line of lines) {
84 if (isMetaLine(line)) {
85 if (filters.meta) out.push(line)
86 continue
87 }
88 if (!line.message) continue
89
90 const content = filterContent(line.message.content, filters)
91 if (content === undefined) continue
92
93 const kept: Line = { type: line.type }
94 if (line.uuid) kept.uuid = line.uuid
95 if (line.timestamp) kept.timestamp = line.timestamp
96 if (line.cwd) kept.cwd = line.cwd
97 if (line.gitBranch) kept.gitBranch = line.gitBranch
98 if (line.isCompactSummary) kept.isCompactSummary = true
99
100 kept.message = {}
101 if (line.message.role) kept.message.role = line.message.role
102 if (line.message.model) kept.message.model = line.message.model
103 kept.message.content = content
104
105 out.push(kept)
106 }
107 return out
108}
109
110/** The kept content, or undefined when nothing is left. */
111function filterContent(
112 content: string | Array<ContentItem> | undefined,
113 filters: Filters,
114): string | Array<ContentItem> | undefined {
115 if (typeof content === 'string') return filters.text && content.trim() ? content : undefined
116 if (!Array.isArray(content)) return undefined
117
118 const kept: Array<ContentItem> = []
119 for (const item of content) {
120 const block = filterBlock(item, filters)
121 if (block) kept.push(block)
122 }
123 return kept.length > 0 ? kept : undefined
124}
125
126function filterBlock(item: ContentItem, filters: Filters): ContentItem | undefined {
127 switch (item.type) {
128 case 'text': {
129 const text = (item as { text: string }).text
130 return filters.text && text.trim() ? { type: 'text', text } : undefined
131 }
132 case 'image':
133 return filters.text ? { type: 'image' } : undefined
134 case 'tool_use': {
135 if (!filters.toolUse) return undefined
136 const { id, name, input } = item as { id: string; name: string; input?: unknown }
137 return filters.toolInput && input !== undefined ? { type: 'tool_use', id, name, input } : { type: 'tool_use', id, name }
138 }
139 case 'tool_result':
140 return filters.toolResults ? item : undefined
141 case 'thinking': {
142 const { thinking } = item as { thinking: string }
143 return filters.thinking && thinking.trim() ? { type: 'thinking', thinking } : undefined
144 }
145 default:
146 return undefined
147 }
148}
149hooks/exporter.ts 148 lines1import type { Config } from './config'
2import { gitInfoOf } from './git'
3import type { Host } from './host'
4import { factsOf, parseMeta, serializeMeta, withAgent } from './meta'
5import type { AgentMeta, SessionMeta } from './meta'
6import { redactSecrets } from './redact'
7import type { Redaction } from './redact'
8import { renderMarkdown } from './render'
9import { filterTranscript, keepsEverything, parseTranscript, serializeTranscript } from './transcript'
10
11/** The folder under the export folder that holds every session; the only path a commit stages. */
12export const SESSIONS_DIR = 'sessions'
13
14/** The engine refuses file reads past 4 MiB; larger transcripts stream through `cat` instead. */
15const MAX_FS_READ_BYTES = 4 * 1_024 * 1_024
16
17export type SessionSource = {
18 sessionId: string
19 transcriptPath: string
20 /** The session's working directory, for a transcript that records none. */
21 cwd: string
22}
23
24export type AgentSource = {
25 sessionId: string
26 agentId: string
27 agentType: string
28 transcriptPath: string
29}
30
31export function sessionDirOf(exportDir: string, sessionId: string): string {
32 return `${exportDir}/${SESSIONS_DIR}/${sessionId}`
33}
34
35/** Writes the session's meta file and the formats the config asks for; resolves the secrets found, by kind. */
36export async function exportSession(host: Host, config: Config, exportDir: string, source: SessionSource) {
37 const dir = sessionDirOf(exportDir, source.sessionId)
38 const raw = await readText(host, source.transcriptPath)
39 const lines = parseTranscript(raw)
40 const facts = factsOf(lines)
41
42 const secrets = await writeFormats(host, config, `${dir}/${source.sessionId}`, raw)
43
44 const cwd = facts.cwd ?? source.cwd
45 const previous = parseMeta(await readIfExists(host, metaPathOf(dir, source.sessionId)))
46 const meta: SessionMeta = {
47 sessionId: source.sessionId,
48 cwd,
49 git: await gitInfoOf(host, cwd, facts.gitBranch),
50 startedAt: facts.startedAt,
51 updatedAt: facts.updatedAt,
52 turns: facts.turns,
53 agents: previous?.agents ?? [],
54 export: exportInfo(config),
55 }
56 await host.write(metaPathOf(dir, source.sessionId), serializeMeta(meta))
57 return secrets
58}
59
60/**
61 * Writes the subagent's formats under its session's `agents` folder and
62 * records it in the session's meta file. A session whose own export has not
63 * run yet gets a meta file that holds the agent alone; the session's export
64 * fills in the rest and keeps the agent.
65 *
66 * Resolves the secrets found, by kind, or false, with nothing written,
67 * for an agent Claude Code keeps no transcript of: internal agents end like
68 * any other but are never saved.
69 */
70export async function exportAgent(host: Host, config: Config, exportDir: string, source: AgentSource) {
71 if (!(await host.exists(source.transcriptPath))) return false
72
73 const dir = sessionDirOf(exportDir, source.sessionId)
74 const raw = await readText(host, source.transcriptPath)
75 const facts = factsOf(parseTranscript(raw))
76
77 const secrets = await writeFormats(host, config, `${dir}/agents/${source.agentId}`, raw)
78
79 const agent: AgentMeta = {
80 agentId: source.agentId,
81 agentType: source.agentType,
82 startedAt: facts.startedAt,
83 updatedAt: facts.updatedAt,
84 turns: facts.turns,
85 }
86 const previous = parseMeta(await readIfExists(host, metaPathOf(dir, source.sessionId)))
87 const meta: SessionMeta = previous ?? {
88 sessionId: source.sessionId,
89 git: null,
90 turns: 0,
91 agents: [],
92 export: exportInfo(config),
93 }
94 await host.write(metaPathOf(dir, source.sessionId), serializeMeta({ ...meta, agents: withAgent(meta.agents, agent) }))
95 return secrets
96}
97
98function metaPathOf(dir: string, sessionId: string): string {
99 return `${dir}/${sessionId}.meta.json`
100}
101
102function exportInfo(config: Config): SessionMeta['export'] {
103 return {
104 formats: config.formats,
105 filters: config.filters,
106 redactSecrets: config.redactSecrets,
107 exportedAt: new Date().toISOString(),
108 }
109}
110
111/**
112 * `base` is the path without its extension. With every filter on, the JSONL
113 * is the transcript as it is, but for the secrets taken out of it. The scan
114 * also runs with redaction off, to tell how many secrets were exported.
115 * Resolves the secrets of the format that holds the most, since each format
116 * holds the same ones.
117 */
118async function writeFormats(host: Host, config: Config, base: string, raw: string): Promise<Redaction['kinds']> {
119 const isWhole = keepsEverything(config.filters)
120 const lines = filterTranscript(parseTranscript(raw), config.filters)
121 const scan = (text: string) => {
122 const found = redactSecrets(text)
123 return config.redactSecrets ? found : { ...found, text }
124 }
125 let most: Redaction | undefined
126
127 if (config.formats.includes('jsonl')) {
128 const jsonl = scan(isWhole ? raw : serializeTranscript(lines))
129 await host.write(`${base}.jsonl`, jsonl.text)
130 most = jsonl
131 }
132 if (config.formats.includes('markdown')) {
133 const markdown = scan(renderMarkdown(lines))
134 await host.write(`${base}.md`, markdown.text)
135 if (!most || markdown.count > most.count) most = markdown
136 }
137 return most?.kinds ?? {}
138}
139
140async function readIfExists(host: Host, path: string): Promise<string | undefined> {
141 return (await host.exists(path)) ? readText(host, path) : undefined
142}
143
144async function readText(host: Host, path: string): Promise<string> {
145 const size = await host.size(path)
146 return size <= MAX_FS_READ_BYTES ? host.read(path) : host.runText(['cat', path])
147}
148hooks/git.ts 321 lines1import type { ProcessRunInit, ProcessRunResult } from 'claude-code'
2
3import type { Host } from './host'
4import type { GitInfo } from './meta'
5
6/** Another session of this mod can hold the index lock of the same repository for a moment. */
7const LOCK_RETRIES = 3
8const LOCK_WAIT_MS = 500
9const GIT_TIMEOUT_MS = 60_000
10
11/**
12 * The engine stops every git process of this mod within its timeout, so an
13 * index lock or a rebase older than this, which has not moved since, belongs
14 * to a git process that was killed. The margin covers a clock that is off.
15 */
16const STALE_AFTER_MS = 2 * GIT_TIMEOUT_MS
17
18/** Keeps the export files of a repair, so they can be got back if the repair stops half way. */
19const REPAIR_REF = 'refs/agent-history/repair'
20
21export class GitError extends Error {
22 constructor(argv: ReadonlyArray<string>, result: ProcessRunResult) {
23 const detail = (result.stderr || result.stdout).trim().split('\n').slice(-1)[0] ?? ''
24 super(`git ${argv[0] ?? ''} failed (${result.exitCode})${detail ? `: ${detail}` : ''}`)
25 }
26}
27
28/** Runs git in `cwd`, again after a short wait while another process holds the index lock. */
29export async function git(
30 host: Host,
31 cwd: string,
32 argv: ReadonlyArray<string>,
33 timeoutMs = GIT_TIMEOUT_MS,
34 init: Pick<ProcessRunInit, 'env' | 'stdin'> = {},
35): Promise<ProcessRunResult> {
36 let result = await host.run(['git', ...argv], { ...init, cwd, timeoutMs })
37 for (let attempt = 1; attempt < LOCK_RETRIES && isLocked(result); attempt++) {
38 await host.sleep(LOCK_WAIT_MS * attempt)
39 result = await host.run(['git', ...argv], { ...init, cwd, timeoutMs })
40 }
41 return result
42}
43
44/** Like `git`, but a non-zero exit throws. */
45async function gitOk(
46 host: Host,
47 cwd: string,
48 argv: ReadonlyArray<string>,
49 timeoutMs?: number,
50 init?: Pick<ProcessRunInit, 'env' | 'stdin'>,
51) {
52 const result = await git(host, cwd, argv, timeoutMs, init)
53 if (result.exitCode !== 0) throw new GitError(argv, result)
54 return result.stdout.trim()
55}
56
57function isLocked(result: ProcessRunResult): boolean {
58 return result.exitCode !== 0 && result.stderr.includes('index.lock')
59}
60
61/** The export folder as git sees it. */
62export type RepoState = {
63 isRepo: boolean
64 remote?: string
65 branch?: string
66}
67
68/**
69 * Whether the folder is the root of a repository, and its push target. A
70 * folder inside some other repository does not count: commits there would
71 * land in a repository the person did not pick.
72 */
73export async function repoState(host: Host, dir: string): Promise<RepoState> {
74 if (!(await host.exists(`${dir}/.git`))) return { isRepo: false }
75
76 const branch = (await git(host, dir, ['branch', '--show-current'])).stdout.trim() || undefined
77 const upstream = await git(host, dir, ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}'])
78 if (upstream.exitCode === 0) {
79 const remote = upstream.stdout.trim().split('/')[0]
80 return { isRepo: true, branch, remote }
81 }
82
83 const remotes = (await git(host, dir, ['remote'])).stdout.trim().split('\n').filter(Boolean)
84 const remote = remotes.includes('origin') ? 'origin' : remotes[0]
85 return { isRepo: true, branch, remote }
86}
87
88export async function initRepo(host: Host, dir: string): Promise<void> {
89 await gitOk(host, dir, ['init'])
90}
91
92/**
93 * Stages the given paths and commits them. Resolves the new commit's short
94 * sha, or undefined when nothing under the paths changed.
95 */
96export async function commit(
97 host: Host,
98 dir: string,
99 paths: ReadonlyArray<string>,
100 message: string,
101): Promise<string | undefined> {
102 await gitOk(host, dir, ['add', '-A', '--', ...paths])
103 const staged = await git(host, dir, ['diff', '--cached', '--quiet', '--', ...paths])
104 if (staged.exitCode === 0) return undefined
105
106 await gitOk(host, dir, ['commit', '--quiet', '--no-verify', '-m', message, '--', ...paths])
107 return gitOk(host, dir, ['rev-parse', '--short', 'HEAD'])
108}
109
110/**
111 * Pushes the current branch. A push the remote rejects as behind fetches the
112 * remote and pushes a merge of the two: each session writes only its own
113 * folder, so the merge does not conflict.
114 *
115 * The fetch is the slow step and the one the short timeout of the session's
116 * end can kill; a killed fetch leaves nothing half done. The merge is made in
117 * memory and pushed before the branch, the index and the work tree move, so a
118 * kill can leave at most a stale index lock, which the next sync repairs. A
119 * git older than 2.38 cannot merge in memory and rebases instead.
120 */
121export async function push(host: Host, dir: string, state: RepoState, timeoutMs?: number): Promise<void> {
122 if (!state.remote || !state.branch) throw new Error('no remote or no branch to push')
123
124 const argv = ['push', '--quiet', '-u', state.remote, `HEAD:${state.branch}`]
125 const first = await git(host, dir, argv, timeoutMs)
126 if (first.exitCode === 0) return
127
128 const isBehind = /rejected|fetch first|non-fast-forward/.test(first.stderr)
129 if (!isBehind) throw new GitError(argv, first)
130
131 const tracking = `refs/remotes/${state.remote}/${state.branch}`
132 await gitOk(host, dir, ['fetch', '--quiet', state.remote, `+refs/heads/${state.branch}:${tracking}`], timeoutMs)
133 const merged = await mergeAndPush(host, dir, state.remote, state.branch, tracking, timeoutMs)
134 if (merged) return
135
136 await rebaseOnto(host, dir, tracking)
137 await gitOk(host, dir, argv, timeoutMs)
138}
139
140/**
141 * Merges the fetched remote into HEAD without the index or the work tree,
142 * pushes the merge, then moves the branch to it. The branch moves only from
143 * the commit the merge started from, so a commit another session made in the
144 * meantime is not lost; the next push merges it. Resolves false when git has
145 * no in-memory merge.
146 */
147async function mergeAndPush(
148 host: Host,
149 dir: string,
150 remote: string,
151 branch: string,
152 tracking: string,
153 timeoutMs?: number,
154): Promise<boolean> {
155 const [head, theirs] = (await gitOk(host, dir, ['rev-parse', 'HEAD^{commit}', `${tracking}^{commit}`])).split('\n')
156 if (!head || !theirs) throw new Error(`could not read HEAD and ${tracking}`)
157
158 const merge = ['merge-tree', '--write-tree', head, theirs]
159 const result = await git(host, dir, merge)
160 if (result.exitCode > 1) return false
161 if (result.exitCode === 1) {
162 const conflicts = result.stdout.split('\n').filter(line => line.startsWith('CONFLICT')).map(line => line.replace(/^CONFLICT \([^)]*\): /, ''))
163 throw new Error(`the remote changed the same files: ${conflicts.join(', ')}; merge ${tracking} by hand`)
164 }
165
166 const tree = result.stdout.trim().split('\n')[0] ?? ''
167 const commit = await gitOk(host, dir, ['commit-tree', tree, '-p', head, '-p', theirs, '-m', `sync: merge ${remote}/${branch}`])
168 await gitOk(host, dir, ['push', '--quiet', remote, `${commit}:refs/heads/${branch}`], timeoutMs)
169 await gitOk(host, dir, ['read-tree', '-m', '-u', head, commit])
170 await gitOk(host, dir, ['update-ref', `refs/heads/${branch}`, commit, head])
171 return true
172}
173
174/** Rebases the branch onto the fetched remote. A rebase that fails is aborted at once, so the next sync does not find it half done. */
175async function rebaseOnto(host: Host, dir: string, tracking: string): Promise<void> {
176 const rebase = ['rebase', '--quiet', '--autostash', tracking]
177 const rebased = await git(host, dir, rebase)
178 if (rebased.exitCode !== 0) {
179 await git(host, dir, ['rebase', '--abort'])
180 throw new GitError(rebase, rebased)
181 }
182}
183
184/** A rebase left in the repository: by `git rebase` with either backend, or by `git am`. */
185type HalfRebase = {
186 isAm: boolean
187 /** Whether it has commands other than `pick`, or stopped by itself, as for an `edit` or a conflict: then a person started it or must go on with it. */
188 isForPerson: boolean
189 modifiedAt: number
190}
191
192/**
193 * Repairs what a git process of this mod leaves when it is killed: an index
194 * lock that no process holds any more, and a rebase that a sync started and
195 * never finished: the rebase of a git without an in-memory merge, or the
196 * `git pull --rebase` of earlier versions of this mod. Every later commit fails on either until they
197 * are gone. Resolves what it did, or undefined when there was nothing to do.
198 *
199 * It repairs only what is old enough that no live git process works on it,
200 * and only a rebase that has nothing but picks and did not stop for a person.
201 * It aborts that rebase, which puts the branch back on its local commits; the
202 * next push that the remote rejects rebases them again. The abort resets the
203 * work tree, so the export files in it are first kept in a commit under
204 * `refs/agent-history/repair`, and written back after the abort. Anything
205 * else throws and says what a person has to do.
206 */
207export async function repair(host: Host, dir: string, paths: ReadonlyArray<string>): Promise<string | undefined> {
208 const gitDir = await gitOk(host, dir, ['rev-parse', '--absolute-git-dir'])
209 const lock = `${gitDir}/index.lock`
210 const hasLock = await host.exists(lock)
211 const rebase = await halfRebase(host, gitDir)
212 if (!hasLock && !rebase) return undefined
213
214 const now = await host.now()
215 if (hasLock && now - (await host.mtime(lock)) < STALE_AFTER_MS) {
216 throw new Error(`another git process holds ${lock}; trying again at the next sync`)
217 }
218 if (rebase) {
219 if (rebase.isAm) throw new Error(`git am is in progress in ${dir}; finish it, or run git am --abort there`)
220 const unmerged = await gitOk(host, dir, ['ls-files', '--unmerged'])
221 if (rebase.isForPerson || unmerged) {
222 throw new Error(`a rebase in ${dir} waits for a person; finish it with git rebase --continue, or run git rebase --abort there`)
223 }
224 if (now - rebase.modifiedAt < STALE_AFTER_MS) {
225 throw new Error(`a rebase is running in ${dir}; trying again at the next sync`)
226 }
227 }
228
229 const done: Array<string> = []
230 const kept = rebase ? await keepFiles(host, dir, gitDir, paths) : undefined
231 if (hasLock) {
232 const moved = await host.run(['mv', '-f', lock, `${lock}.stale`], { cwd: dir })
233 if (moved.exitCode !== 0) throw new Error(`could not move the stale ${lock} away: ${moved.stderr.trim()}`)
234 done.push('moved a stale index.lock away')
235 }
236 if (kept) {
237 await gitOk(host, dir, ['reset', '--quiet', '--hard'])
238 await gitOk(host, dir, ['clean', '-f', '-d', '-q', '--', ...paths])
239 await gitOk(host, dir, ['rebase', '--abort'])
240 if (kept.changed.length > 0) {
241 const stdin = kept.changed.map(path => `${path}\0`).join('')
242 await gitOk(host, dir, ['checkout', kept.commit, '--pathspec-from-file=-', '--pathspec-file-nul'], undefined, { stdin })
243 }
244 done.push(`aborted a rebase that was killed, kept ${kept.changed.length} export file${kept.changed.length === 1 ? '' : 's'}`)
245 }
246 return done.join(', ')
247}
248
249async function halfRebase(host: Host, gitDir: string): Promise<HalfRebase | undefined> {
250 const merge = `${gitDir}/rebase-merge`
251 if (await host.exists(`${merge}/head-name`)) {
252 const todo = `${merge}/git-rebase-todo`
253 const lines = [await readIf(host, `${merge}/done`), await readIf(host, todo)].join('\n').split('\n')
254 const commands = lines.map(line => line.trim()).filter(line => line && !line.startsWith('#'))
255 const isForPerson = commands.some(line => !line.startsWith('pick ')) || (await host.exists(`${merge}/stopped-sha`))
256 return { isAm: false, isForPerson, modifiedAt: await newest(host, [todo, `${merge}/head-name`]) }
257 }
258
259 const apply = `${gitDir}/rebase-apply`
260 if (await host.exists(apply)) {
261 const isAm = !(await host.exists(`${apply}/rebasing`))
262 return { isAm, isForPerson: false, modifiedAt: await newest(host, [`${apply}/next`, apply]) }
263 }
264 return undefined
265}
266
267/**
268 * Writes the export files as they are in the work tree to a commit, through
269 * an index of its own: the repository's index may be locked. Resolves the
270 * commit and the files that differ from HEAD, which the abort would lose.
271 */
272async function keepFiles(host: Host, dir: string, gitDir: string, paths: ReadonlyArray<string>) {
273 const env = { GIT_INDEX_FILE: `${gitDir}/agent-history-repair-index` }
274 await gitOk(host, dir, ['read-tree', 'HEAD'], undefined, { env })
275 await gitOk(host, dir, ['add', '-A', '--', ...paths], undefined, { env })
276 const tree = await gitOk(host, dir, ['write-tree'], undefined, { env })
277 const commit = await gitOk(host, dir, ['commit-tree', tree, '-p', 'HEAD', '-m', 'agent-history: export files before a repair'])
278 await gitOk(host, dir, ['update-ref', REPAIR_REF, commit])
279 const diff = await gitOk(host, dir, ['diff', '--name-only', '-z', '--no-renames', '--diff-filter=d', 'HEAD', commit, '--', ...paths])
280 return { commit, changed: diff.split('\0').filter(Boolean) }
281}
282
283async function readIf(host: Host, path: string): Promise<string> {
284 return (await host.exists(path)) ? host.read(path) : ''
285}
286
287/** The newest modification time of the paths that exist. */
288async function newest(host: Host, paths: ReadonlyArray<string>): Promise<number> {
289 let at = 0
290 for (const path of paths) if (await host.exists(path)) at = Math.max(at, await host.mtime(path))
291 return at
292}
293
294/**
295 * The repository a session's working directory belongs to. The name comes
296 * from the common git dir, not the working tree root: a worktree's root is
297 * named after its branch, and every worktree of a repository should agree.
298 * Resolves null outside a repository or when the directory is gone.
299 */
300export async function gitInfoOf(host: Host, cwd: string, branch: string | undefined): Promise<GitInfo | null> {
301 const result = await git(host, cwd, ['rev-parse', '--path-format=absolute', '--git-common-dir', '--show-toplevel'])
302 if (result.exitCode !== 0) return null
303
304 const [commonDir, worktree] = result.stdout.trim().split('\n')
305 if (!commonDir || !worktree) return null
306
307 const root = basename(commonDir) === '.git' ? dirname(commonDir) : commonDir
308 const repo = basename(root).replace(/\.git$/, '') || basename(worktree)
309 const current = branch ?? ((await git(host, cwd, ['branch', '--show-current'])).stdout.trim() || undefined)
310
311 return { repo, branch: current, worktree }
312}
313
314function basename(path: string): string {
315 return path.replace(/\/+$/, '').split('/').pop() ?? ''
316}
317
318function dirname(path: string): string {
319 return path.replace(/\/+$/, '').split('/').slice(0, -1).join('/') || '/'
320}
321hooks/redact.ts 86 lines1/**
2 * Finds secrets in exported text and replaces each one with `[REDACTED:<kind>]`.
3 *
4 * The text is the file as it is written: JSONL with its JSON escapes, or
5 * Markdown. No pattern takes a `"` or a `\`, except the private key block,
6 * which starts and ends on plain characters; so a redacted JSONL line is still
7 * valid JSON.
8 */
9
10export type Redaction = {
11 text: string
12 // How many secrets were found.
13 count: number
14 // How many secrets of each kind were found.
15 kinds: Record<string, number>
16}
17
18type Rule = {
19 kind: string
20 pattern: RegExp
21 // The capture group that holds the secret; the whole match when absent.
22 group?: number
23 // A last check on the secret, for patterns that also match harmless text.
24 isSecret?: (value: string) => boolean
25}
26
27// Known token formats first, so the generic rules do not take their names.
28const RULES: ReadonlyArray<Rule> = [
29 { kind: 'private-key', pattern: /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g },
30 { kind: 'anthropic', pattern: /\bsk-ant-[A-Za-z0-9_-]{20,}/g },
31 { kind: 'openai', pattern: /\bsk-(?:proj-|svcacct-|admin-)?[A-Za-z0-9_-]{20,}/g, isSecret: hasLetterAndDigit },
32 { kind: 'github', pattern: /\b(?:gh[pousr]_[A-Za-z0-9]{36,255}|github_pat_[A-Za-z0-9_]{22,255})\b/g },
33 { kind: 'aws', pattern: /\b(?:AKIA|ASIA|ABIA|ACCA)[0-9A-Z]{16}\b/g },
34 { kind: 'google', pattern: /\bAIza[0-9A-Za-z_-]{35}\b/g },
35 { kind: 'stripe', pattern: /\b[rs]k_(?:live|test)_[A-Za-z0-9]{16,}\b/g },
36 { kind: 'slack', pattern: /\bxox[abposr]-[A-Za-z0-9-]{10,}/g },
37 { kind: 'slack-webhook', pattern: /https:\/\/hooks\.slack\.com\/services\/[A-Za-z0-9/]+/g },
38 { kind: 'npm', pattern: /\bnpm_[A-Za-z0-9]{36}\b/g },
39 { kind: 'jwt', pattern: /\beyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/g },
40 { kind: 'bearer', pattern: /(\bBearer\s+)([A-Za-z0-9._~+/-]{20,}=*)/g, group: 2 },
41 {
42 kind: 'url-password',
43 pattern: /(\b[a-z][a-z0-9+.-]*:\/\/[^\s:/@"'\\]+:)([^\s@/"'\\]{3,})(@)/g,
44 group: 2,
45 isSecret: value => !isPlaceholder(value),
46 },
47 {
48 kind: 'assignment',
49 pattern:
50 /(\b[A-Za-z0-9_-]*(?:api[_-]?key|secret|token|passw(?:or)?d|credential)[A-Za-z0-9_-]*(?:\\?["'])?\s*[:=]\s*(?:\\?["'])?)([^\s"'\\,;`)}\]]{12,})/gi,
51 group: 2,
52 isSecret: value => hasLetterAndDigit(value) && !isPlaceholder(value),
53 },
54]
55
56const REDACTED = /^\[REDACTED:/
57
58export function redactSecrets(text: string): Redaction {
59 let count = 0
60 const kinds: Record<string, number> = {}
61 let result = text
62 for (const rule of RULES) {
63 result = result.replace(rule.pattern, (match: string, ...groups: Array<unknown>) => {
64 const secret = rule.group === undefined ? match : String(groups[rule.group - 1] ?? '')
65 if (REDACTED.test(secret) || (rule.isSecret && !rule.isSecret(secret))) return match
66 count += 1
67 kinds[rule.kind] = (kinds[rule.kind] ?? 0) + 1
68 const label = `[REDACTED:${rule.kind}]`
69 if (rule.group === undefined) return label
70 const before = groups.slice(0, rule.group - 1).join('')
71 const after = groups.slice(rule.group, groups.length - 2).filter(part => typeof part === 'string').join('')
72 return `${before}${label}${after}`
73 })
74 }
75 return { text: result, count, kinds }
76}
77
78function hasLetterAndDigit(value: string): boolean {
79 return /[A-Za-z]/.test(value) && /\d/.test(value)
80}
81
82// Values that stand for a secret in docs and code, not a secret itself.
83function isPlaceholder(value: string): boolean {
84 return /^(?:\$\{?|<|\*{3,}|x{4,}|\.{3})|process\.env|REDACTED/i.test(value)
85}
86hooks/render.ts 92 lines1import { isMetaLine } from './transcript'
2import type { ContentItem, Line } from './transcript'
3
4/**
5 * The conversation as Markdown, one section per run of consecutive messages
6 * from the same role. A transcript stores each tool call as its own message,
7 * so without the merge a single assistant reply would split into a section
8 * per tool it called. A section carries the timestamp of its first message.
9 *
10 * It renders what the lines hold: filtering happens before, so the Markdown
11 * and the JSONL of an export always show the same content.
12 */
13export function renderMarkdown(lines: Array<Line>): string {
14 const sections: Array<{ role: string; timestamp?: string; parts: Array<string> }> = []
15 for (const line of lines) {
16 const role = roleOf(line)
17 if (!role) continue
18 const body = renderContent(line.message?.content)
19 if (!body.trim()) continue
20
21 const last = sections[sections.length - 1]
22 if (last?.role === role) {
23 last.parts.push(body)
24 } else {
25 sections.push({ role, timestamp: line.timestamp, parts: [body] })
26 }
27 }
28
29 const text = sections
30 .map(({ role, timestamp, parts }) => {
31 const heading = timestamp ? `## ${role} - ${timestamp}` : `## ${role}`
32 return `${heading}\n\n${parts.join('\n\n')}`
33 })
34 .join('\n\n')
35 return text ? text + '\n' : ''
36}
37
38/** A meta line renders only when it carries a message; attachments and the like have none. */
39function roleOf(line: Line): string | undefined {
40 if (line.type !== 'user' && line.type !== 'assistant') return undefined
41 if (isMetaLine(line)) return 'Meta'
42 return line.type === 'user' ? 'User' : 'Assistant'
43}
44
45function renderContent(content: string | Array<ContentItem> | undefined): string {
46 if (content === undefined) return ''
47 if (typeof content === 'string') return content
48 return content.map(renderItem).filter(Boolean).join('\n\n')
49}
50
51function renderItem(item: ContentItem): string {
52 switch (item.type) {
53 case 'text': {
54 const { text } = item as { text: string }
55 return text.trim() ? text : ''
56 }
57 case 'image':
58 return '_[image omitted]_'
59 case 'tool_use': {
60 const { name, input } = item as { name: string; input?: unknown }
61 const head = `_[Tool: ${name}]_`
62 return input === undefined ? head : `${head}\n\n${fence(JSON.stringify(input, null, 2), 'json')}`
63 }
64 case 'tool_result': {
65 const { content, is_error } = item as { content?: string | Array<ContentItem>; is_error?: boolean }
66 const summary = is_error === true ? 'Tool error' : 'Tool result'
67 return `<details><summary>${summary}</summary>\n\n${fence(resultText(content))}\n\n</details>`
68 }
69 case 'thinking': {
70 const { thinking } = item as { thinking: string }
71 return `<details><summary>Thinking</summary>\n\n${thinking}\n\n</details>`
72 }
73 default:
74 return ''
75 }
76}
77
78function resultText(content: string | Array<ContentItem> | undefined): string {
79 if (content === undefined) return ''
80 if (typeof content === 'string') return content
81 return content
82 .map(item => (item.type === 'text' ? (item as { text: string }).text : `[${item.type}]`))
83 .join('\n')
84}
85
86/** A fence longer than any backtick run inside, so the content cannot close it. */
87function fence(text: string, language = ''): string {
88 const longest = Math.max(2, ...Array.from(text.matchAll(/`+/g), match => match[0].length))
89 const ticks = '`'.repeat(longest + 1)
90 return `${ticks}${language}\n${text}\n${ticks}`
91}
92types/index.d.ts 52 lines1/** What git can do with the export folder. */
2export type AgentHistoryMode = 'unset' | 'export-only' | 'commit' | 'push'
3
4/** The export folder as the last check found it. */
5export type AgentHistoryRepo = {
6 exportDir: string
7 isRepo: boolean
8 remote?: string
9 branch?: string
10}
11
12/** One step of a sync, newest last in the log. */
13export type AgentHistoryLogEntry = {
14 at: number
15 step: 'check' | 'init' | 'export' | 'secret' | 'repair' | 'commit' | 'push'
16 isOk: boolean
17 message: string
18}
19
20/** The secrets found in the exports of this session. */
21export type AgentHistorySecrets = {
22 count: number
23 /** Whether they were redacted, or exported as they are. */
24 isRedacted: boolean
25 /** Set when the panel was opened after the last new secret; the status line then stops to show it. */
26 isSeen: boolean
27}
28
29/** What the status line and the panel show. */
30export type AgentHistoryStatus = {
31 mode: AgentHistoryMode
32 repo?: AgentHistoryRepo
33 lastExportAt?: number
34 lastCommit?: { sha: string; at: number }
35 lastPushAt?: number
36 /** Sessions exported since the last commit. */
37 pending: Array<string>
38 /** Set while the last step failed or the repository has no remote; cleared by the next success. */
39 warning?: string
40 /** Set once an export of this session found a secret. */
41 secrets?: AgentHistorySecrets
42}
43
44declare module 'claude-code' {
45 interface PluginState {
46 'agent-history': {
47 status: AgentHistoryStatus
48 log: Array<AgentHistoryLogEntry>
49 }
50 }
51}
52