SLOPSHOPPER

agent-history

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

newpanecommandstatusprocesstimer
v0.3.2MITupdated 2026-10-23zirkelc/agent-history/claude-code
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · agent-history
│ ┃ agent-history ✕ › fix the failing auth test and add an audit log call │ ┃ AGENT HISTORY ✕ Close │ ┃ ⏺ Read(src/auth.ts) │ ┃ Session preview-session ⎿ Read 6 lines │ ┃ Folder not set: /config ⏺ Update(src/auth.ts) │ ┃ Git - ⎿ Added 2 lines, removed 1 line │ ┃ Formats JSONL · Markdown ⏺ Bash(bun test) │ ┃ Content text · tool calls ⎿ 3 pass, 1 fail │ ┃ Secrets redacted │ ┃ Change the settings in /config ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ ────────────────────────────────────────── │ ┃ ─────────── ✻ Worked for 42s · done 4:20 PM │ ┃ Activity in this session │ ┃ Nothing exported in this session yet. The › /agent-history │ ┃ next turn exports it. ⎿ agent-history: agent-history panel opened. │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ agent-history: set the export folder in /config

Draws

Pane · agent-history
AGENT HISTORY ✕ Close Session preview-session Folder not set: /config Git - Formats JSONL · Markdown Content text · tool calls Secrets redacted Change the settings in /config ───────────────────────────────────────────────────── Activity in this session Nothing exported in this session yet. The next turn exports it. ───────────────────────────────────────────────────── ⚠ set the export folder in /config
README

Agent History for Claude Code

<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.

Install

/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.

Source 12 files
hooks/register.tsx 137 lines
1import { 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}
137
hooks/config.ts 50 lines
1import 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}
50
hooks/host.ts 30 lines
1import 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}
30
hooks/pane-view.tsx 315 lines
1import 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}
315
hooks/sync.ts 271 lines
1import 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}
271
hooks/meta.ts 88 lines
1import { 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}
88
hooks/transcript.ts 149 lines
1/**
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}
149
hooks/exporter.ts 148 lines
1import 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}
148
hooks/git.ts 321 lines
1import 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}
321
hooks/redact.ts 86 lines
1/**
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}
86
hooks/render.ts 92 lines
1import { 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}
92
types/index.d.ts 52 lines
1/** 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