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

A Claude Code mod that keeps a history of your sessions. After each turn, it exports the session and its subagents to a folder you choose. When that folder is a git repository, the mod commits the export and pushes it.
meta.json file for each session.Type these commands at the prompt of a Claude Code terminal session:
/plugin marketplace add zirkelc/agent-plugins
/plugin install agent-history@zirkelc
Select a scope (user scope is the default), then set the export folder on the settings screen.
Open /config and search for agent-history to change the settings.
| Setting | Default | What it does |
|---|---|---|
| Export folder | (none) | Absolute path or ~/... of the folder to export to. Until it is set, nothing is exported and the status line asks you to set it |
| Export JSONL | on | Write <uuid>.jsonl for each session and subagent |
| Export Markdown | on | Write <uuid>.md for each session and subagent |
| Include text | on | Keep the text of user and assistant messages |
| Include tool calls | on | Keep each tool call with its tool name |
| Include tool input | off | Keep the input of each tool call |
| Include tool results | off | Keep tool results (the largest part of a transcript) |
| Include thinking | off | Keep thinking blocks |
| Include meta rows | off | Keep rows Claude Code adds itself (reminders, attachments, system rows) |
| Push after commit | on | Push each commit when the repository has a remote |
| Commit delay (seconds) | 60 | Wait this long after the last export, so turns close together make one commit |
With every content filter on, the JSONL file is the raw transcript.
| Command | What it does |
|---|---|
/agent-history | Opens the panel |
The panel shows the export folder, the git state, the formats and the content filters, and a log of each export, commit and push. It has these actions:
git init in the export folder (shown when the folder is not a repository). The mod never runs git init by itself.The mod shows a status line only when there is a problem, for example push failed or no remote. The mod never creates a remote. To push, add one yourself, for example with gh repo create <name> --private --source <folder> --push.
| Hook | What happens |
|---|---|
session.start | Read the git state of the export folder and register the command |
classic.Stop (end of each turn) | Export the session |
classic.SubagentStop (end of each subagent) | Export the subagent |
| Timer, 60 s after the last export | Commit and push |
session.end | Export, commit and push at once |
<export folder>
└── sessions
└── <session-id>
├── <session-id>.jsonl
├── <session-id>.md
├── <session-id>.meta.json
└── agents
├── <agent-id>.jsonl
└── <agent-id>.md
These examples show one short session with the default settings (text and tool calls, no tool input, no tool results, no thinking, no meta rows).
<session-id>.meta.json holds the facts of the session, its subagents, and the export settings that made the files:
{
"sessionId": "0b7c8e2a-4f1d-4c3a-9e6b-2d5f8a1c7e90",
"cwd": "/Users/me/Developer/my-app",
"git": {
"repo": "my-app",
"branch": "main",
"worktree": "/Users/me/Developer/my-app"
},
"startedAt": "2026-10-12T09:00:00.000Z",
"updatedAt": "2026-10-12T09:00:05.000Z",
"turns": 1,
"agents": [
{
"agentId": "a1b2c3d4e5f6a7b8c",
"agentType": "Explore",
"startedAt": "2026-10-12T09:00:02.000Z",
"updatedAt": "2026-10-12T09:00:04.000Z",
"turns": 1
}
],
"export": {
"formats": ["jsonl", "markdown"],
"filters": {
"text": true,
"toolUse": true,
"toolInput": false,
"toolResults": false,
"thinking": false,
"meta": false
},
"exportedAt": "2026-10-12T09:00:06.000Z"
}
}
git is null when the session ran outside a git repository.
<session-id>.jsonl has one message on each line. With every filter on, it is the raw transcript. Otherwise each line keeps only type, uuid, timestamp, cwd, gitBranch and the message with the content you selected:
{"type":"user","uuid":"5d1e…","timestamp":"2026-10-12T09:00:00.000Z","cwd":"/Users/me/Developer/my-app","gitBranch":"main","message":{"role":"user","content":"Why does the login test fail?"}}
{"type":"assistant","uuid":"8f3a…","timestamp":"2026-10-12T09:00:01.000Z","cwd":"/Users/me/Developer/my-app","gitBranch":"main","message":{"role":"assistant","model":"claude-opus-5-5","content":[{"type":"text","text":"I will look at the test first."},{"type":"tool_use","id":"toolu_01…","name":"Read"}]}}
{"type":"assistant","uuid":"c47b…","timestamp":"2026-10-12T09:00:05.000Z","cwd":"/Users/me/Developer/my-app","gitBranch":"main","message":{"role":"assistant","model":"claude-opus-5-5","content":[{"type":"text","text":"The test uses an expired token. I updated the fixture."}]}}
<session-id>.md has one section for each run of messages from the same role. A tool call shows its name. With tool input on, the input follows in a JSON block. Tool results and thinking show in a collapsed <details> block:
## User - 2026-10-12T09:00:00.000Z
Why does the login test fail?
## Assistant - 2026-10-12T09:00:01.000Z
I will look at the test first.
_[Tool: Read]_
The test uses an expired token. I updated the fixture.
agents/<agent-id>.jsonl and agents/<agent-id>.md have the same format as the session files, for the transcript of one subagent.
pnpm install
pnpm check
pnpm check runs claude plugin validate . and claude plugin test .. pnpm install also sets up a git pre-commit hook (with simple-git-hooks) that runs pnpm check before each commit.
MIT
hooks/register.tsx 129 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. */
62 on('command.run', { command: 'agent-history' }, async $ => {
63 void sync?.check()
64 await $.ui.open(PANE)
65
66 return { text: 'agent-history panel opened.' }
67 })
68
69 /** The view uses elements the terminal and the desktop both have; elsewhere the engine draws its own. */
70 on('ui.render', { component: 'Pane', requestId: PANE_ID }, ($, e, next) =>
71 e.surface === 'terminal' || e.surface === 'desktop' ? renderPane($, e, config, () => sync) : next(e),
72 )
73}
74
75/** The engine as the export and git code sees it, bound to the session's `$`. */
76function hostOf($: EngineInterface): Host {
77 return {
78 run: (argv, init) => $.process.run(argv, init),
79 runText: async argv => {
80 const parts: Array<string> = []
81 for await (const chunk of $.process.spawn({ argv })) {
82 if (chunk.stream === 'stdout') parts.push(chunk.text)
83 }
84 return parts.join('')
85 },
86 read: async path => (await $.fs.read(path)) as string,
87 write: (path, text) => $.fs.write(path, text),
88 exists: path => $.fs.exists(path),
89 size: async path => (await $.fs.stat(path)).size,
90 home: () => $.env.get('HOME'),
91 now: () => $.clock.now(),
92 sleep: ms => $.clock.sleep(ms),
93 after: (ms, fn) => $.clock.after(ms, fn),
94 status: text => $.ui.status(text),
95 readStatus: () => read($, status),
96 updateStatus: change => update($, status, change),
97 updateLog: change => update($, log, change),
98 }
99}
100
101async function renderPane(
102 $: EngineInterface,
103 e: RenderInput<'Pane'>,
104 config: Config,
105 syncOf: () => Sync | undefined,
106) {
107 const { Box, Text, Button } = $.ui.resolve(e)
108 const [value, entries, sessionId] = await Promise.all([read($, status), read($, log), $.session.id()])
109
110 return paneView(
111 { Box, Text, Button },
112 {
113 syncNow: () => void syncOf()?.syncNow(),
114 initRepo: () => void syncOf()?.initRepo(),
115 close: () => void $.ui.close({ id: PANE_ID }),
116 },
117 {
118 status: value,
119 log: entries,
120 sessionId,
121 config,
122 columns: Math.max(e.props.bodyColumns - 2 * PADDING - 1, 20),
123 bodyRows: e.props.scroll.bodyRows,
124 isDocked: e.props.placement === 'dock',
125 surface: e.surface,
126 },
127 )
128}
129hooks/config.ts 47 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 autoPush: boolean
12 commitDelayMs: number
13}
14
15const DEFAULT_COMMIT_DELAY_SECONDS = 60
16
17/** The options are untyped at run time, so each value is checked and falls back to its manifest default. */
18export function configOf(options: PluginOptions): Config {
19 const flag = (key: string, fallback: boolean) => (typeof options[key] === 'boolean' ? (options[key] as boolean) : fallback)
20 const seconds = typeof options.commitDelaySeconds === 'number' ? options.commitDelaySeconds : DEFAULT_COMMIT_DELAY_SECONDS
21
22 const formats: Array<Format> = []
23 if (flag('exportJsonl', true)) formats.push('jsonl')
24 if (flag('exportMarkdown', true)) formats.push('markdown')
25
26 return {
27 exportDir: typeof options.exportDir === 'string' ? options.exportDir.trim() : '',
28 formats,
29 filters: {
30 text: flag('includeText', true),
31 toolUse: flag('includeToolUse', true),
32 toolInput: flag('includeToolInput', false),
33 toolResults: flag('includeToolResults', false),
34 thinking: flag('includeThinking', false),
35 meta: flag('includeMeta', false),
36 },
37 autoPush: flag('autoPush', true),
38 commitDelayMs: Math.max(0, seconds) * 1_000,
39 }
40}
41
42export function expandHome(path: string, home: string | undefined): string {
43 if (!home) return path
44 if (path === '~') return home
45 return path.startsWith('~/') ? `${home}${path.slice(1)}` : path
46}
47hooks/host.ts 28 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 /** The home directory, for an export folder written with `~`. */
19 home: () => Promise<string | undefined>
20 now: () => Promise<number>
21 sleep: (ms: number) => Promise<void>
22 after: (ms: number, fn: () => void) => Timer
23 status: (text: string | undefined) => void
24 readStatus: () => Promise<AgentHistoryStatus>
25 updateStatus: (change: (value: AgentHistoryStatus) => AgentHistoryStatus) => Promise<AgentHistoryStatus>
26 updateLog: (change: (list: Array<AgentHistoryLogEntry>) => Array<AgentHistoryLogEntry>) => Promise<unknown>
27}
28hooks/pane-view.tsx 297 lines1import type { ElementTable, RenderElement } from 'claude-code'
2
3import type { AgentHistoryLogEntry, AgentHistoryStatus } from '../types'
4import type { Config } from './config'
5import { clockTime } 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 config: Config
25 /** The columns inside the pane's padding. */
26 columns: number
27 /** The rows the pane's body has room for. */
28 bodyRows: number
29 /** Whether the pane is docked, with rows of its own to fill, rather than inline, where the frame fits the tree. */
30 isDocked: boolean
31 /** The terminal gets colored buttons of its own, the other surfaces their native ones. */
32 surface: string
33}
34
35/** The space between the pane's frame and its content, on the left and on the right. */
36export const PADDING = 1
37
38/** The background of an action button in the terminal: the blue of a VS Code button, readable in both themes. */
39const BUTTON_COLOR = '#0e639c'
40
41/** The mark of a folder kept in git. */
42const SYNC_ICON = '⇅'
43
44/** The columns a row's label takes, so the values of every row start in one column. */
45const LABEL_WIDTH = 'Content'.length
46
47/** The rows around the log: header and margin, five info rows and the hint, two rules, the log's title, the status line. */
48const OUTER_ROWS = 2 + 6 + 2 + 1 + 1
49
50/** The width of the step column in the log. */
51const STEP_WIDTH = 'commit'.length
52
53/** The most rows the foot gives a warning; a longer one is cut. */
54const WARNING_ROWS = 3
55
56/** The fewest log rows shown, however small the pane. */
57const MIN_LOG_ROWS = 3
58
59const FILTER_LABELS: ReadonlyArray<[keyof Filters, string]> = [
60 ['text', 'text'],
61 ['toolUse', 'tool calls'],
62 ['toolInput', 'tool input'],
63 ['toolResults', 'tool results'],
64 ['thinking', 'thinking'],
65 ['meta', 'meta'],
66]
67
68const FORMAT_LABELS: Record<Config['formats'][number], string> = { jsonl: 'JSONL', markdown: 'Markdown' }
69
70type ButtonLook = 'primary' | 'neutral'
71
72/** In the terminal a label on a colored ground or as plain text; elsewhere the surface's own button. */
73function actionButton(
74 ui: Ui,
75 model: PaneModel,
76 key: string,
77 label: string,
78 onPress: () => void,
79 look: ButtonLook = 'primary',
80): RenderElement {
81 const { Box, Button } = ui
82 if (model.surface !== 'terminal') {
83 return (
84 <Button key={key} variant={look === 'primary' ? 'primary' : 'secondary'} onPress={onPress}>
85 {label}
86 </Button>
87 )
88 }
89 return (
90 <Box key={`box:${key}`} paddingX={1} {...(look === 'primary' ? { backgroundColor: BUTTON_COLOR } : {})}>
91 <Button key={key} plain onPress={onPress}>
92 {label}
93 </Button>
94 </Box>
95 )
96}
97
98function ruleView(ui: Ui, model: PaneModel, key: string): RenderElement {
99 const { Text } = ui
100 return (
101 <Text key={key} dimColor>
102 {'─'.repeat(model.columns)}
103 </Text>
104 )
105}
106
107/** One info row: a dim label padded to the labels' width, then its value. */
108function row(ui: Ui, key: string, label: string, value: RenderElement | string): RenderElement {
109 const { Box, Text } = ui
110 return (
111 <Box key={key} flexDirection="row" columnGap={1}>
112 <Text dimColor>{label.padEnd(LABEL_WIDTH, ' ')}</Text>
113 {typeof value === 'string' ? <Text wrap="truncate">{value}</Text> : value}
114 </Box>
115 )
116}
117
118/** A path cut from the front to a width, so its last parts, which tell it apart, stay. */
119export function pathFit(path: string, width: number): string {
120 const chars = [...path]
121 if (chars.length <= width) return path
122 return width <= 1 ? '…' : `…${chars.slice(chars.length - width + 1).join('')}`
123}
124
125/** A text cut at the end to a width. */
126export function fit(text: string, width: number): string {
127 const chars = [...text.replace(/\s+/g, ' ')]
128 return chars.length <= width ? chars.join('') : `${chars.slice(0, Math.max(width - 1, 0)).join('')}…`
129}
130
131function headerView(ui: Ui, actions: PaneActions, model: PaneModel): RenderElement {
132 const { Box, Text } = ui
133 const repo = model.status.repo
134 return (
135 <Box flexDirection="row" justifyContent="space-between" marginBottom={1}>
136 <Text bold color="cyan">
137 AGENT HISTORY
138 </Text>
139 <Box flexDirection="row" columnGap={2}>
140 {repo?.isRepo && actionButton(ui, model, 'sync', `${SYNC_ICON} Sync now`, actions.syncNow)}
141 {repo && !repo.isRepo && actionButton(ui, model, 'init', '+ Init git repo', actions.initRepo)}
142 {actionButton(ui, model, 'close', '✕ Close', actions.close, 'neutral')}
143 </Box>
144 </Box>
145 )
146}
147
148/** What git does with the folder: its remote in green, a missing remote in yellow, no repository dim. */
149function gitValue(ui: Ui, model: PaneModel): RenderElement {
150 const { Text } = ui
151 const { mode, repo } = model.status
152 if (!repo) return <Text dimColor>-</Text>
153 if (!repo.isRepo) return <Text dimColor>not a repository: export only</Text>
154 if (!repo.remote) return <Text color="yellow">{`${SYNC_ICON} ${repo.branch ?? ''}, no remote: commit only`}</Text>
155 const target = `${repo.remote}/${repo.branch ?? ''}`
156 return mode === 'push' ? (
157 <Text color="green">{`${SYNC_ICON} ${target}`}</Text>
158 ) : (
159 <Text dimColor>{`${SYNC_ICON} ${target}, push off: commit only`}</Text>
160 )
161}
162
163function infoView(ui: Ui, model: PaneModel): Array<RenderElement> {
164 const { Text } = ui
165 const { config, status } = model
166 const folder = status.repo?.exportDir ?? config.exportDir
167 const formats = config.formats.map(format => FORMAT_LABELS[format]).join(' · ') || 'meta only'
168 const content = FILTER_LABELS.filter(([key]) => config.filters[key])
169 .map(([, label]) => label)
170 .join(' · ')
171 return [
172 row(ui, 'session', 'Session', fit(model.sessionId, model.columns - LABEL_WIDTH - 1)),
173 row(
174 ui,
175 'folder',
176 'Folder',
177 folder ? pathFit(folder, model.columns - LABEL_WIDTH - 1) : <Text color="yellow">not set: /config</Text>,
178 ),
179 row(ui, 'git', 'Git', gitValue(ui, model)),
180 row(ui, 'formats', 'Formats', formats),
181 row(ui, 'content', 'Content', fit(content || 'nothing', model.columns - LABEL_WIDTH - 1)),
182 <Text key="hint" dimColor italic wrap="truncate">
183 {fit('Change the settings in /config', model.columns)}
184 </Text>,
185 ]
186}
187
188/** One log line, always one row so the rows below never move: the dim time, a green or red dot, the step, then what it did. */
189function logRow(ui: Ui, model: PaneModel, entry: AgentHistoryLogEntry, at: number): RenderElement {
190 const { Box, Text } = ui
191 const room = model.columns - 5 - 1 - 1 - 1 - STEP_WIDTH - 1
192 return (
193 <Box key={`log:${at}`} flexDirection="row" columnGap={1}>
194 <Text dimColor>{clockTime(entry.at)}</Text>
195 <Text color={entry.isOk ? 'green' : 'red'}>⏺</Text>
196 <Text bold={!entry.isOk}>{entry.step.padEnd(STEP_WIDTH, ' ')}</Text>
197 <Text dimColor={entry.isOk} color={entry.isOk ? undefined : 'red'} wrap="truncate">
198 {fit(entry.message, room)}
199 </Text>
200 </Box>
201 )
202}
203
204function logView(ui: Ui, model: PaneModel): Array<RenderElement> {
205 const { Text } = ui
206 const rows = Math.max(model.bodyRows - OUTER_ROWS - (footRowsOf(model) - 1), MIN_LOG_ROWS)
207 const shown = model.log.slice(-rows).reverse()
208 const lines =
209 shown.length === 0
210 ? [
211 <Text key="empty" dimColor>
212 Nothing exported in this session yet. The next turn exports it.
213 </Text>,
214 ]
215 : shown.map((entry, at) => logRow(ui, model, entry, at))
216 /** A docked pane fills its rows, so the status line stays at its foot. */
217 const pad = model.isDocked
218 ? Array.from({ length: Math.max(rows - lines.length, 0) }, (_, at) => <Text key={`pad:${at}`}> </Text>)
219 : []
220 return [...lines, ...pad]
221}
222
223/** The warning with the message of the step that failed, which says what to fix. */
224function warningText(model: PaneModel): string | undefined {
225 const { warning } = model.status
226 if (!warning) return undefined
227 const failed = [...model.log].reverse().find(entry => !entry.isOk)
228 const detail = failed && warning.startsWith(failed.step) ? `: ${failed.message}` : ''
229 return `⚠ ${warning}${detail}`
230}
231
232/** A text cut into rows of a width, at most `count` of them, the last ending in `…` when more is left. */
233export function rowsOf(text: string, width: number, count: number): Array<string> {
234 const chars = [...text.replace(/\s+/g, ' ')]
235 const rows: Array<string> = []
236 for (let at = 0; at < chars.length && rows.length < count; at += width) rows.push(chars.slice(at, at + width).join(''))
237 if (chars.length > width * count) rows[count - 1] = [...(rows[count - 1] ?? '')].slice(0, width - 1).join('') + '…'
238 return rows
239}
240
241/** The foot's rows: as many as the warning needs, up to a few; the log gives up the same rows. */
242function footRowsOf(model: PaneModel): number {
243 const warning = warningText(model)
244 return warning ? Math.max(rowsOf(warning, model.columns, WARNING_ROWS).length, 1) : 1
245}
246
247/** The foot: the warning 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. */
248function statusView(ui: Ui, model: PaneModel): RenderElement {
249 const { Box, Text } = ui
250 const { status } = model
251 const warning = warningText(model)
252 if (warning) {
253 const color = status.warning === 'no remote' || status.mode === 'unset' ? 'yellow' : 'red'
254 const rows = rowsOf(warning, model.columns, WARNING_ROWS)
255 return (
256 <Box key="status" flexDirection="column">
257 {rows.map((text, at) => (
258 <Text key={`warning:${at}`} color={color} wrap="truncate">
259 {text}
260 </Text>
261 ))}
262 </Box>
263 )
264 }
265 const time = (ms: number | undefined) => (ms === undefined ? '-' : clockTime(ms))
266 const parts = [
267 `export ${time(status.lastExportAt)}`,
268 ...(status.repo?.isRepo ? [`commit ${status.lastCommit ? `${status.lastCommit.sha} ${clockTime(status.lastCommit.at)}` : '-'}`] : []),
269 ...(status.mode === 'push' ? [`push ${time(status.lastPushAt)}`] : []),
270 ...(status.pending.length > 0 ? [`${status.pending.length} pending`] : []),
271 ]
272 return (
273 <Box key="status" flexDirection="row" justifyContent="space-between" width={model.columns}>
274 <Text dimColor wrap="truncate">
275 {fit(parts.join(' · '), model.columns - 2)}
276 </Text>
277 <Text color="green">✓</Text>
278 </Box>
279 )
280}
281
282/** The whole pane: the header and its actions, what the export does, the activity log, the status line. */
283export function paneView(ui: Ui, actions: PaneActions, model: PaneModel): RenderElement {
284 const { Box, Text } = ui
285 return (
286 <Box flexDirection="column" paddingX={PADDING}>
287 {headerView(ui, actions, model)}
288 {infoView(ui, model)}
289 {ruleView(ui, model, 'rule:log')}
290 <Text bold>Activity in this session</Text>
291 {logView(ui, model)}
292 {ruleView(ui, model, 'rule:status')}
293 {statusView(ui, model)}
294 </Box>
295 )
296}
297hooks/sync.ts 217 lines1import type { Timer } from 'claude-code'
2
3import type { AgentHistoryLogEntry, AgentHistoryMode, 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, repoState } from './git'
9import type { Host } from './host'
10import type { RepoState } from './git'
11
12const MAX_LOG = 50
13
14export type Sync = ReturnType<typeof createSync>
15
16/**
17 * The export and git work of one session. Every step runs on one queue, so
18 * exports and commits never overlap within the session; other sessions that
19 * share the export folder are covered by the retries of the git helpers.
20 */
21export function createSync(host: Host, config: Config) {
22 let exportDir = ''
23 let repo: RepoState = { isRepo: false }
24 let queue: Promise<unknown> = Promise.resolve()
25 let timer: Timer | undefined
26 /** The last transcript seen per session, exported once more when the session ends. */
27 const sources = new Map<string, SessionSource>()
28
29 function enqueue<T>(task: () => Promise<T>): Promise<T | undefined> {
30 const run = queue.then(task).catch(() => undefined)
31 queue = run
32 return run
33 }
34
35 async function record(step: AgentHistoryLogEntry['step'], isOk: boolean, message: string) {
36 const entry: AgentHistoryLogEntry = { at: await host.now(), step, isOk, message }
37 await host.updateLog(list => [...list, entry].slice(-MAX_LOG))
38 }
39
40 async function setStatus(change: (value: AgentHistoryStatus) => AgentHistoryStatus) {
41 const next = await host.updateStatus(change)
42 host.status(statusLine(next))
43 }
44
45 async function fail(step: AgentHistoryLogEntry['step'], error: unknown) {
46 /** The engine names the mod before its own errors; the panel already says whose they are. */
47 const message = (error instanceof Error ? error.message : String(error)).replace(/^agent-history: /, '')
48 await record(step, false, message)
49 await setStatus(value => ({ ...value, warning: `${step} failed` }))
50 }
51
52 /** Reads the export folder and the repository; changes nothing on disk. */
53 async function check() {
54 const home = await host.home()
55 exportDir = expandHome(config.exportDir, home).replace(/\/+$/, '')
56
57 if (!exportDir) {
58 repo = { isRepo: false }
59 await setStatus(value => ({ ...value, mode: 'unset', repo: undefined, warning: 'set the export folder in /config' }))
60 return
61 }
62
63 try {
64 repo = await repoState(host, exportDir)
65 } catch (error) {
66 repo = { isRepo: false }
67 await fail('check', error)
68 return
69 }
70
71 const mode: AgentHistoryMode = !repo.isRepo ? 'export-only' : repo.remote && config.autoPush ? 'push' : 'commit'
72 const warning = repo.isRepo && !repo.remote ? 'no remote' : undefined
73 await setStatus(value => ({ ...value, mode, repo: { exportDir, ...repo }, warning }))
74 }
75
76 function schedule() {
77 if (!repo.isRepo) return
78 timer?.cancel()
79 timer = host.after(config.commitDelayMs, () => {
80 timer = undefined
81 void enqueue(commitAndPush)
82 })
83 }
84
85 async function exported(sessionId: string, message: string) {
86 const at = await host.now()
87 await record('export', true, message)
88 await setStatus(value => ({
89 ...value,
90 lastExportAt: at,
91 pending: value.pending.includes(sessionId) ? value.pending : [...value.pending, sessionId],
92 warning: value.warning === 'export failed' ? undefined : value.warning,
93 }))
94 schedule()
95 }
96
97 async function commitAndPush(timeoutMs?: number) {
98 if (!repo.isRepo) return
99
100 const { pending } = await host.readStatus()
101 const message = pending.length > 0 ? `sync: ${pending.join(', ')}` : 'sync'
102
103 let sha: string | undefined
104 try {
105 sha = await commit(host, exportDir, [SESSIONS_DIR], message)
106 } catch (error) {
107 return fail('commit', error)
108 }
109
110 const at = await host.now()
111 if (sha) await record('commit', true, `${sha} ${message}`)
112 await setStatus(value => ({
113 ...value,
114 pending: value.pending.filter(id => !pending.includes(id)),
115 lastCommit: sha ? { sha, at } : value.lastCommit,
116 warning: value.warning === 'commit failed' ? undefined : value.warning,
117 }))
118
119 if (!config.autoPush || !repo.remote) return
120 try {
121 await push(host, exportDir, repo, timeoutMs)
122 } catch (error) {
123 return fail('push', error)
124 }
125 await record('push', true, `${repo.remote}/${repo.branch ?? ''}`)
126 await setStatus(value => ({
127 ...value,
128 lastPushAt: at,
129 warning: value.warning === 'push failed' ? undefined : value.warning,
130 }))
131 }
132
133 return {
134 check: () => enqueue(check),
135
136 exportSession(source: SessionSource) {
137 sources.set(source.sessionId, source)
138 return enqueue(async () => {
139 if (!exportDir) return
140 try {
141 await exportSession(host, config, exportDir, source)
142 } catch (error) {
143 return fail('export', error)
144 }
145 await exported(source.sessionId, `session ${source.sessionId}`)
146 })
147 },
148
149 exportAgent(source: AgentSource) {
150 return enqueue(async () => {
151 if (!exportDir) return
152 try {
153 const isSaved = await exportAgent(host, config, exportDir, source)
154 if (!isSaved) return
155 } catch (error) {
156 return fail('export', error)
157 }
158 await exported(source.sessionId, `agent ${source.agentType} ${source.agentId}`)
159 })
160 },
161
162 /** Reads the repository again, since a remote can be added at any time, then commits and pushes without waiting for the timer. */
163 syncNow(timeoutMs?: number) {
164 timer?.cancel()
165 timer = undefined
166 return enqueue(async () => {
167 await check()
168 await commitAndPush(timeoutMs)
169 })
170 },
171
172 /** Exports the session one last time, then commits and pushes within the time the end leaves. */
173 async end(sessionId: string, timeoutMs: number) {
174 const source = sources.get(sessionId)
175 if (source) await this.exportSession(source)
176 timer?.cancel()
177 timer = undefined
178 /** No new check of the repository here: the end's time is short, and the start's check stands. */
179 await enqueue(() => commitAndPush(timeoutMs))
180 },
181
182 /** Turns the export folder into a repository; only ever on the person's request. */
183 initRepo() {
184 return enqueue(async () => {
185 if (!exportDir) return
186 try {
187 await initRepo(host, exportDir)
188 } catch (error) {
189 return fail('init', error)
190 }
191 await record('init', true, exportDir)
192 await check()
193 })
194 },
195
196 dispose() {
197 timer?.cancel()
198 timer = undefined
199 },
200 }
201}
202
203/**
204 * The status line: the warning while one stands, otherwise none. Claude Code
205 * draws every mod's status line as a warning with the mod's name in front, so
206 * a line for a good state would read as a problem.
207 */
208export function statusLine(value: AgentHistoryStatus): string | undefined {
209 return value.warning
210}
211
212export function clockTime(ms: number): string {
213 const date = new Date(ms)
214 const pad = (n: number) => String(n).padStart(2, '0')
215 return `${pad(date.getHours())}:${pad(date.getMinutes())}`
216}
217hooks/meta.ts 87 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 exportedAt: string
32 }
33}
34
35/** What the transcript itself says about its session, read from the whole, unfiltered lines. */
36export type TranscriptFacts = {
37 cwd?: string
38 gitBranch?: string
39 startedAt?: string
40 updatedAt?: string
41 turns: number
42}
43
44export function factsOf(lines: Array<Line>): TranscriptFacts {
45 const stamped = lines.filter(line => typeof line.timestamp === 'string')
46 const cwd = lines.find(line => typeof line.cwd === 'string')?.cwd
47 const gitBranch = [...lines].reverse().find(line => typeof line.gitBranch === 'string' && line.gitBranch)?.gitBranch
48
49 return {
50 cwd,
51 gitBranch,
52 startedAt: stamped[0]?.timestamp,
53 updatedAt: stamped[stamped.length - 1]?.timestamp,
54 turns: lines.filter(isPromptLine).length,
55 }
56}
57
58/** A turn starts with a prompt the person typed: a user message that is neither meta nor only tool results. */
59function isPromptLine(line: Line): boolean {
60 if (line.type !== 'user' || isMetaLine(line)) return false
61 const content = line.message?.content
62 if (typeof content === 'string') return content.trim().length > 0
63 return Array.isArray(content) && content.some(item => item.type === 'text')
64}
65
66/** Keeps the agents an earlier export recorded and puts this one in its place, ordered by start. */
67export function withAgent(agents: Array<AgentMeta>, agent: AgentMeta): Array<AgentMeta> {
68 return [...agents.filter(one => one.agentId !== agent.agentId), agent].sort((a, b) =>
69 (a.startedAt ?? '').localeCompare(b.startedAt ?? ''),
70 )
71}
72
73/** The meta of an earlier export, or undefined when there is none or it does not parse. */
74export function parseMeta(text: string | undefined): SessionMeta | undefined {
75 if (text === undefined) return undefined
76 try {
77 const meta = JSON.parse(text) as SessionMeta
78 return typeof meta.sessionId === 'string' ? meta : undefined
79 } catch {
80 return undefined
81 }
82}
83
84export function serializeMeta(meta: SessionMeta): string {
85 return JSON.stringify(meta, null, 2) + '\n'
86}
87hooks/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 125 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 { renderMarkdown } from './render'
7import { filterTranscript, keepsEverything, parseTranscript, serializeTranscript } from './transcript'
8
9/** The folder under the export folder that holds every session; the only path a commit stages. */
10export const SESSIONS_DIR = 'sessions'
11
12/** The engine refuses file reads past 4 MiB; larger transcripts stream through `cat` instead. */
13const MAX_FS_READ_BYTES = 4 * 1_024 * 1_024
14
15export type SessionSource = {
16 sessionId: string
17 transcriptPath: string
18 /** The session's working directory, for a transcript that records none. */
19 cwd: string
20}
21
22export type AgentSource = {
23 sessionId: string
24 agentId: string
25 agentType: string
26 transcriptPath: string
27}
28
29export function sessionDirOf(exportDir: string, sessionId: string): string {
30 return `${exportDir}/${SESSIONS_DIR}/${sessionId}`
31}
32
33/** Writes the session's meta file and the formats the config asks for. */
34export async function exportSession(host: Host, config: Config, exportDir: string, source: SessionSource) {
35 const dir = sessionDirOf(exportDir, source.sessionId)
36 const raw = await readText(host, source.transcriptPath)
37 const lines = parseTranscript(raw)
38 const facts = factsOf(lines)
39
40 await writeFormats(host, config, `${dir}/${source.sessionId}`, raw)
41
42 const cwd = facts.cwd ?? source.cwd
43 const previous = parseMeta(await readIfExists(host, metaPathOf(dir, source.sessionId)))
44 const meta: SessionMeta = {
45 sessionId: source.sessionId,
46 cwd,
47 git: await gitInfoOf(host, cwd, facts.gitBranch),
48 startedAt: facts.startedAt,
49 updatedAt: facts.updatedAt,
50 turns: facts.turns,
51 agents: previous?.agents ?? [],
52 export: exportInfo(config),
53 }
54 await host.write(metaPathOf(dir, source.sessionId), serializeMeta(meta))
55}
56
57/**
58 * Writes the subagent's formats under its session's `agents` folder and
59 * records it in the session's meta file. A session whose own export has not
60 * run yet gets a meta file that holds the agent alone; the session's export
61 * fills in the rest and keeps the agent.
62 *
63 * Resolves false, with nothing written, for an agent Claude Code keeps no
64 * transcript of: internal agents end like any other but are never saved.
65 */
66export async function exportAgent(host: Host, config: Config, exportDir: string, source: AgentSource) {
67 if (!(await host.exists(source.transcriptPath))) return false
68
69 const dir = sessionDirOf(exportDir, source.sessionId)
70 const raw = await readText(host, source.transcriptPath)
71 const facts = factsOf(parseTranscript(raw))
72
73 await writeFormats(host, config, `${dir}/agents/${source.agentId}`, raw)
74
75 const agent: AgentMeta = {
76 agentId: source.agentId,
77 agentType: source.agentType,
78 startedAt: facts.startedAt,
79 updatedAt: facts.updatedAt,
80 turns: facts.turns,
81 }
82 const previous = parseMeta(await readIfExists(host, metaPathOf(dir, source.sessionId)))
83 const meta: SessionMeta = previous ?? {
84 sessionId: source.sessionId,
85 git: null,
86 turns: 0,
87 agents: [],
88 export: exportInfo(config),
89 }
90 await host.write(metaPathOf(dir, source.sessionId), serializeMeta({ ...meta, agents: withAgent(meta.agents, agent) }))
91 return true
92}
93
94function metaPathOf(dir: string, sessionId: string): string {
95 return `${dir}/${sessionId}.meta.json`
96}
97
98function exportInfo(config: Config): SessionMeta['export'] {
99 return { formats: config.formats, filters: config.filters, exportedAt: new Date().toISOString() }
100}
101
102/** `base` is the path without its extension. With every filter on, the JSONL is the transcript as it is. */
103async function writeFormats(host: Host, config: Config, base: string, raw: string) {
104 if (config.formats.length === 0) return
105
106 const isWhole = keepsEverything(config.filters)
107 const lines = filterTranscript(parseTranscript(raw), config.filters)
108
109 if (config.formats.includes('jsonl')) {
110 await host.write(`${base}.jsonl`, isWhole ? raw : serializeTranscript(lines))
111 }
112 if (config.formats.includes('markdown')) {
113 await host.write(`${base}.md`, renderMarkdown(lines))
114 }
115}
116
117async function readIfExists(host: Host, path: string): Promise<string | undefined> {
118 return (await host.exists(path)) ? readText(host, path) : undefined
119}
120
121async function readText(host: Host, path: string): Promise<string> {
122 const size = await host.size(path)
123 return size <= MAX_FS_READ_BYTES ? host.read(path) : host.runText(['cat', path])
124}
125hooks/git.ts 139 lines1import type { 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
11export class GitError extends Error {
12 constructor(argv: ReadonlyArray<string>, result: ProcessRunResult) {
13 const detail = (result.stderr || result.stdout).trim().split('\n').slice(-1)[0] ?? ''
14 super(`git ${argv[0] ?? ''} failed (${result.exitCode})${detail ? `: ${detail}` : ''}`)
15 }
16}
17
18/** Runs git in `cwd`, again after a short wait while another process holds the index lock. */
19export async function git(
20 host: Host,
21 cwd: string,
22 argv: ReadonlyArray<string>,
23 timeoutMs = GIT_TIMEOUT_MS,
24): Promise<ProcessRunResult> {
25 let result = await host.run(['git', ...argv], { cwd, timeoutMs })
26 for (let attempt = 1; attempt < LOCK_RETRIES && isLocked(result); attempt++) {
27 await host.sleep(LOCK_WAIT_MS * attempt)
28 result = await host.run(['git', ...argv], { cwd, timeoutMs })
29 }
30 return result
31}
32
33/** Like `git`, but a non-zero exit throws. */
34async function gitOk(host: Host, cwd: string, argv: ReadonlyArray<string>, timeoutMs?: number) {
35 const result = await git(host, cwd, argv, timeoutMs)
36 if (result.exitCode !== 0) throw new GitError(argv, result)
37 return result.stdout.trim()
38}
39
40function isLocked(result: ProcessRunResult): boolean {
41 return result.exitCode !== 0 && result.stderr.includes('index.lock')
42}
43
44/** The export folder as git sees it. */
45export type RepoState = {
46 isRepo: boolean
47 remote?: string
48 branch?: string
49}
50
51/**
52 * Whether the folder is the root of a repository, and its push target. A
53 * folder inside some other repository does not count: commits there would
54 * land in a repository the person did not pick.
55 */
56export async function repoState(host: Host, dir: string): Promise<RepoState> {
57 if (!(await host.exists(`${dir}/.git`))) return { isRepo: false }
58
59 const branch = (await git(host, dir, ['branch', '--show-current'])).stdout.trim() || undefined
60 const upstream = await git(host, dir, ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}'])
61 if (upstream.exitCode === 0) {
62 const remote = upstream.stdout.trim().split('/')[0]
63 return { isRepo: true, branch, remote }
64 }
65
66 const remotes = (await git(host, dir, ['remote'])).stdout.trim().split('\n').filter(Boolean)
67 const remote = remotes.includes('origin') ? 'origin' : remotes[0]
68 return { isRepo: true, branch, remote }
69}
70
71export async function initRepo(host: Host, dir: string): Promise<void> {
72 await gitOk(host, dir, ['init'])
73}
74
75/**
76 * Stages the given paths and commits them. Resolves the new commit's short
77 * sha, or undefined when nothing under the paths changed.
78 */
79export async function commit(
80 host: Host,
81 dir: string,
82 paths: ReadonlyArray<string>,
83 message: string,
84): Promise<string | undefined> {
85 await gitOk(host, dir, ['add', '-A', '--', ...paths])
86 const staged = await git(host, dir, ['diff', '--cached', '--quiet', '--', ...paths])
87 if (staged.exitCode === 0) return undefined
88
89 await gitOk(host, dir, ['commit', '--quiet', '--no-verify', '-m', message, '--', ...paths])
90 return gitOk(host, dir, ['rev-parse', '--short', 'HEAD'])
91}
92
93/**
94 * Pushes the current branch. A push the remote rejects as behind is rebased
95 * onto the remote once and pushed again: each session writes only its own
96 * folder, so the rebase does not conflict.
97 */
98export async function push(host: Host, dir: string, state: RepoState, timeoutMs?: number): Promise<void> {
99 if (!state.remote || !state.branch) throw new Error('no remote or no branch to push')
100
101 const argv = ['push', '--quiet', '-u', state.remote, `HEAD:${state.branch}`]
102 const first = await git(host, dir, argv, timeoutMs)
103 if (first.exitCode === 0) return
104
105 const isBehind = /rejected|fetch first|non-fast-forward/.test(first.stderr)
106 if (!isBehind) throw new GitError(argv, first)
107
108 await gitOk(host, dir, ['pull', '--quiet', '--rebase', '--autostash', state.remote, state.branch], timeoutMs)
109 await gitOk(host, dir, argv, timeoutMs)
110}
111
112/**
113 * The repository a session's working directory belongs to. The name comes
114 * from the common git dir, not the working tree root: a worktree's root is
115 * named after its branch, and every worktree of a repository should agree.
116 * Resolves null outside a repository or when the directory is gone.
117 */
118export async function gitInfoOf(host: Host, cwd: string, branch: string | undefined): Promise<GitInfo | null> {
119 const result = await git(host, cwd, ['rev-parse', '--path-format=absolute', '--git-common-dir', '--show-toplevel'])
120 if (result.exitCode !== 0) return null
121
122 const [commonDir, worktree] = result.stdout.trim().split('\n')
123 if (!commonDir || !worktree) return null
124
125 const root = basename(commonDir) === '.git' ? dirname(commonDir) : commonDir
126 const repo = basename(root).replace(/\.git$/, '') || basename(worktree)
127 const current = branch ?? ((await git(host, cwd, ['branch', '--show-current'])).stdout.trim() || undefined)
128
129 return { repo, branch: current, worktree }
130}
131
132function basename(path: string): string {
133 return path.replace(/\/+$/, '').split('/').pop() ?? ''
134}
135
136function dirname(path: string): string {
137 return path.replace(/\/+$/, '').split('/').slice(0, -1).join('/') || '/'
138}
139hooks/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 41 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' | 'commit' | 'push'
16 isOk: boolean
17 message: string
18}
19
20/** What the status line and the panel show. */
21export type AgentHistoryStatus = {
22 mode: AgentHistoryMode
23 repo?: AgentHistoryRepo
24 lastExportAt?: number
25 lastCommit?: { sha: string; at: number }
26 lastPushAt?: number
27 /** Sessions exported since the last commit. */
28 pending: Array<string>
29 /** Set while the last step failed or the repository has no remote; cleared by the next success. */
30 warning?: string
31}
32
33declare module 'claude-code' {
34 interface PluginState {
35 'agent-history': {
36 status: AgentHistoryStatus
37 log: Array<AgentHistoryLogEntry>
38 }
39 }
40}
41