When a session ends, appends a one-line log entry to a markdown tracker file (e.g. in an Obsidian vault).

A Claude Code mod. When a session ends, it appends one line to a markdown tracker file (for example a note in an Obsidian vault):
- 2026-10-10 14:32 · my-project · Fixed the login redirect bug
## Session log heading. The heading is created if it is missing./plugin install tracker-sync --marketplace AliakbarNateghi/cc-tracker-sync
Answer y to add the marketplace, then pick a scope.
trackerPath: absolute path to the tracker file. Empty (the default) turns the mod off.hooks/register.ts 75 lines1import type { EngineInterface, Register } from 'claude-code'
2
3const HEADING = '## Session log'
4
5/** Adds `line` at the end of the `## Session log` section, creating the section if missing. */
6export function addLine(doc: string, line: string): string {
7 const lines = doc.split('\n')
8 const at = lines.findIndex(l => l.trim() === HEADING)
9 if (at < 0) return `${doc.trimEnd() ? doc.trimEnd() + '\n\n' : ''}${HEADING}\n\n${line}\n`
10 let end = at + 1
11 while (end < lines.length && !/^#{1,2} /.test(lines[end] ?? '')) end++
12 while (end > at + 1 && (lines[end - 1] ?? '').trim() === '') end--
13 lines.splice(end, 0, line)
14 return lines.join('\n')
15}
16
17/** The person's own prompts: user rows with text, no tool results, no injected tags. */
18async function prompts($: EngineInterface): Promise<string[]> {
19 const rows = await $.session.messages()
20 return rows
21 .filter(m => m.role === 'user' && !m.toolResults?.length)
22 .map(m => m.text.trim())
23 .filter(t => t && !t.startsWith('<'))
24}
25
26const oneLine = (s: string) => s.replace(/\s+/g, ' ').trim().slice(0, 140)
27
28export const register: Register = (on, options) => {
29 const path = String(options.trackerPath ?? '').trim()
30 if (!path) return
31
32 // session.end allows ~1.5 s in all, too little for a model call, so the
33 // summary is refreshed after each main-thread turn and read at the end.
34 let summary: string | undefined
35 let seen: string[] = []
36
37 on('turn.complete', ($, e, next) => {
38 if (!e.agentId)
39 void (async () => {
40 const ps = (seen = await prompts($))
41 if (!ps.length) return
42 const r = await $.model.complete({
43 model: 'haiku',
44 effort: 'low',
45 maxTokens: 60,
46 timeoutMs: 30_000,
47 prompt:
48 'Summarize what this coding session was about in one short line (max 12 words, no quotes, no trailing period). The user prompts:\n\n' +
49 ps.map(p => `- ${p.slice(0, 500)}`).join('\n'),
50 })
51 if (r.isAnswered && r.text.trim()) summary = oneLine(r.text)
52 })().catch(() => {})
53 return next(e)
54 })
55
56 on('session.end', async ($, e, next) => {
57 // ponytail: some modes (claude -p) have no transcript at session.end; use the last turn's copy.
58 const ps = await prompts($).catch(() => seen)
59 if (ps.length) {
60 const cwd = await $.session.cwd()
61 const git = await $.process.run(['git', 'rev-parse', '--show-toplevel'], { cwd, timeoutMs: 500 })
62 const root = (git.exitCode === 0 && git.stdout.trim()) || cwd
63 const project = root.replace(/\/+$/, '').split('/').pop() || root
64 const date = (await $.process.run(['date', '+%Y-%m-%d %H:%M'], { timeoutMs: 500 })).stdout.trim()
65 const line = `- ${date} · ${project} · ${summary ?? oneLine(ps[0] ?? '')}`
66 // Only a missing file starts empty; any other read error throws, so the file is never clobbered.
67 const doc = (await $.fs.exists(path)) ? await $.fs.read(path) : ''
68 await $.fs.write(path, addLine(doc, line))
69 }
70 summary = undefined
71 seen = []
72 return next(e)
73 })
74}
75