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.1.3MITupdated 2026-10-14zirkelc/agent-history
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 │ ┃ Change the settings in /config │ ┃ ────────────────────────────────────────── ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ ─────────── │ ┃ Activity in this session ✻ Worked for 42s · done 4:20 PM │ ┃ Nothing exported in this session yet. The │ ┃ next turn exports it. › /agent-history │ ┃ ⎿ 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 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

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.

  • Formats: JSONL (raw or filtered) and/or Markdown, plus a meta.json file for each session.
  • Filters: keep only the content you want (text, tool calls, tool input, tool results, thinking, meta rows), so the files stay small.
  • Git is optional: a plain folder gets the export only. A repository gets a commit. A repository with a remote also gets a push.
  • Self-contained: everything runs in Claude Code's lifecycle hooks. No launchd jobs, no cron, no settings hooks.

Install

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.

Settings

Open /config and search for agent-history to change the settings.

SettingDefaultWhat 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 JSONLonWrite <uuid>.jsonl for each session and subagent
Export MarkdownonWrite <uuid>.md for each session and subagent
Include textonKeep the text of user and assistant messages
Include tool callsonKeep each tool call with its tool name
Include tool inputoffKeep the input of each tool call
Include tool resultsoffKeep tool results (the largest part of a transcript)
Include thinkingoffKeep thinking blocks
Include meta rowsoffKeep rows Claude Code adds itself (reminders, attachments, system rows)
Push after commitonPush each commit when the repository has a remote
Commit delay (seconds)60Wait 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.

Usage

CommandWhat it does
/agent-historyOpens 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:

  • ⇅ Sync now: commit and push the pending exports at once (shown when the folder is a git repository).
  • + Init git repo: run git init in the export folder (shown when the folder is not a repository). The mod never runs git init by itself.
  • ✕ Close: close the panel (or press Esc).

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.

When it runs

HookWhat happens
session.startRead 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 exportCommit and push
session.endExport, commit and push at once

Layout

<export folder>
└── sessions
    └── <session-id>
        ├── <session-id>.jsonl
        ├── <session-id>.md
        ├── <session-id>.meta.json
        └── agents
            ├── <agent-id>.jsonl
            └── <agent-id>.md

Examples

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.

Development

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.

License

MIT

Source 11 files
hooks/register.tsx 129 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. */
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}
129
hooks/config.ts 47 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  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}
47
hooks/host.ts 28 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  /** 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}
28
hooks/pane-view.tsx 297 lines
1import 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}
297
hooks/sync.ts 217 lines
1import 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}
217
hooks/meta.ts 87 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    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}
87
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 125 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 { 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}
125
hooks/git.ts 139 lines
1import 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}
139
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 41 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' | '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