Records the reasoning behind your coding sessions — planning discussions, trade-offs, rejected approaches, debugging context — as searchable markdown. Six months later, the chronicle shows not just what was built, but how it was thought through.
Default install is foreground mode. Chronicle always records hook events and injects past-session context into new Claude Code sessions, but does NOT run
claude -por spend tokens unless you explicitly runchronicle process,chronicle insight,chronicle story, orchronicle rewind --summary. Background auto-summarization is opt-in viachronicle install-daemon.
Prerequisites: macOS (Apple Silicon) or Linux (x86_64) · Claude Code CLI 2.1.205 or newer · Claude subscription (Pro / Max / Teams).
The version floor comes from
--json-schema(min-version 2.1.205), which Chronicle uses for structured session summaries;--safe-modeneeds only 2.1.169.
Chronicle ships as a prebuilt self-contained binary. No Python, venv, or system package dependencies on the target machine.
curl -fsSL https://raw.githubusercontent.com/ehzawad/claudetalktoclaude/main/install.sh | bashcurl -fsSL https://raw.githubusercontent.com/ehzawad/claudetalktoclaude/main/install.sh | bash && chronicle install-daemonPin a specific release: CHRONICLE_VERSION=vX.Y.Z curl ... | bash. For mirrors or isolated tests, CHRONICLE_BASE_URL overrides the release host and CHRONICLE_HOME moves Chronicle's data/runtime root. Upgrade: chronicle update.
Restart Claude Code to activate hooks. Then:
chronicle doctor # verify everything resolves
chronicle query projects # show per-project session counts
chronicle process --workers 5 # summarize pending sessions (foreground)If your shell still says chronicle: command not found after install, verify
the symlink before assuming a shell or macOS problem:
command -v chronicle
ls -l ~/.local/bin/chronicle ~/.chronicle/runtime/chronicleIf ~/.chronicle/runtime/chronicle exists but ~/.local/bin/chronicle does
not, rerun install.sh to restore the symlink. A separate tool such as
codex-chronicle does not replace or shadow the chronicle command; they
coexist under different names.
Switch modes anytime:
chronicle install-daemon # → background
chronicle uninstall-daemon # → foregroundflowchart TB
START([curl -fsSL install.sh | bash]) --> PLAT["detect platform<br/>(darwin-arm64 / linux-x86_64)"]
PLAT --> DEPS["check curl, tar, claude<br/>(fail fast if missing)"]
DEPS --> URLS["resolve asset URL<br/>(latest or CHRONICLE_VERSION)"]
URLS --> DL["download chronicle-$TARGET.tar.gz<br/>+ chronicle-$TARGET.tar.gz.sha256"]
DL --> VERIFY{"SHA256 match?"}
VERIFY -->|no| ABORT([abort — no files touched])
VERIFY -->|yes| STOPD["if daemon running,<br/>launchctl bootout /<br/>systemctl stop<br/>(fallback SIGTERM only<br/>for unmanaged daemon)"]
STOPD --> QUAR["macOS: xattr -dr<br/>com.apple.quarantine<br/>(skip Gatekeeper kill)"]
QUAR --> HOOKS["validate new runtime:<br/>chronicle install-hooks<br/>(merge into ~/.claude/settings.json)"]
HOOKS --> SWAP["atomic swap:<br/>runtime.new → runtime<br/>runtime → runtime.old → rm"]
SWAP --> LINK["symlink<br/>~/.local/bin/chronicle{,-hook}<br/>→ runtime/chronicle"]
LINK --> CHMOD["chmod 700 ~/.chronicle/"]
CHMOD --> BG{"mode ==<br/>background<br/>(from config) ?"}
BG -->|yes| KICK["launchctl bootstrap /<br/>systemctl start<br/>(new binary hot)"]
BG -->|no| DONE
KICK --> DONE([restart Claude Code])
CU[["chronicle update"]] -.->|re-runs install.sh<br/>one source of truth| START
CUN[["chronicle uninstall<br/>[--purge]"]] -->|inverse path:<br/>stop daemon → strip hooks →<br/>rm symlinks → rm runtime/| CDN([user data preserved<br/>unless --purge])
| Foreground (default) | Background (opt-in) | |
|---|---|---|
| Hooks record session events | yes | yes |
| Past-session titles injected into new sessions | yes | yes |
| Auto-summarization | no | after 5 min of quiet |
| Passive token burn | zero | per-session |
| Runs a service | no | launchd (macOS) / systemd --user (Linux) |
| Enable | (default) | chronicle install-daemon |
| Disable | (default) | chronicle uninstall-daemon |
Mode is stored in ~/.chronicle/config.json under processing_mode. chronicle doctor reports current mode plus any drift (e.g., config says foreground but a stale daemon is running).
flowchart LR
CFG[("config.json<br/>processing_mode<br/><b>authoritative</b>")]
SVC[("service file<br/>plist / unit<br/><i>managed effect</i>")]
DP[("daemon process<br/><i>managed effect</i>")]
ID[["chronicle install-daemon"]]
UD[["chronicle uninstall-daemon"]]
ID -->|1 set mode=background| CFG
ID -->|2 write service file| SVC
ID -->|3 bootstrap| DP
UD -->|1 bootout| DP
UD -->|2 remove service file| SVC
UD -->|3 set mode=foreground| CFG
CFG --> DOC{chronicle doctor}
SVC --> DOC
DP --> DOC
DOC -->|mode=fg, nothing loaded| OK1([OK])
DOC -->|mode=bg, service running| OK2([OK])
DOC -->|mode=fg + service still loaded| W1["drift:<br/>run uninstall-daemon"]
DOC -->|mode=bg + service missing| W2["drift:<br/>run install-daemon"]
DOC -->|mode=bg + loaded but not running| W3["drift:<br/>check daemon.log"]
DOC -->|claude binary unresolved| F1["fail:<br/>install Claude Code"]
DOC -->|config.json unreadable| F2["fail:<br/>repair config"]
SELF[["daemon loop reads<br/>config each tick"]] -.->|if mode flipped to fg,<br/>idle — do NOT exit<br/>— avoids launchd KeepAlive<br/>restart loop| DP
chronicle query projects # per-project OK / Pend / Fail counts
chronicle query timeline --limit 20 # recent sessions across all projects
chronicle query timeline --project api # recent sessions matching a project
chronicle query sessions [PATH|--project NAME] # a project's chronicle (default: cwd)
chronicle query search "auth" # full-text across all chronicles
chronicle query search "auth" --project api
chronicle query show <project-name> # show a project's chronicle by name
chronicle query <project-name> # shortcut for `query show`Project names are matched by the folder basename you see (e.g. codex-council,
my_proj) as well as any substring of the stored slug — whatever name a command
prints back is a name you can type. Lists (projects, timeline) show the
de-dashed slug to disambiguate same-named folders; single-project views show the
folder basename.
chronicle process --workers 5 # pending sessions
chronicle process --project NAME # match by basename or slug substring
chronicle process --force --workers 5 # reprocess successes
chronicle process --retry-failed --workers 5 # retry terminal failures
chronicle process --dry-run # preview onlychronicle rewind # numbered session list
chronicle rewind <N> # show session #N
chronicle rewind --since <N> # sessions #N through latest
chronicle rewind --diff <N> # what was NEW in session #N
chronicle rewind --summary <N> # AI-summarize #N onward (calls claude -p)
chronicle rewind --project NAME # target a specific project slug match
chronicle rewind --delete <N> # remove one session's records
chronicle rewind --prune # delete sessions with 0 decisions
chronicle insight [project] # HTML dashboard (calls claude -p)
chronicle story [project] # unified narrative md (calls claude -p)chronicle doctor # human-readable diagnostic
chronicle doctor --json # machine-readable (CI-friendly)
chronicle install-daemon # switch to background mode
chronicle uninstall-daemon # switch to foreground mode
chronicle update # fetch + install the latest release, restart daemon if running
chronicle uninstall # remove binary + hooks + daemon; preserve ~/.chronicle data
chronicle uninstall --purge --yes # also delete ~/.chronicle (events.jsonl, config, logs)
chronicle uninstall --dry-run # print what would be removed without executing
chronicle install-hooks [settings] # merge hooks into Claude Code settings
chronicle daemon [--bg|--stop|--status] # raw/manual daemon control
chronicle --versionCommands that spawn
claude -pare:process,insight,story,rewind --summary, and the background daemon. Nothing else spends tokens.
Session — one conversation with Claude Code. Stored as ~/.claude/projects/<slug>/<session-id>.jsonl.
Project slug — Claude Code's own project-directory name under ~/.claude/projects/. It replaces every non-alphanumeric character of the absolute working directory with - (runs not collapsed), so it begins with a dash (from the leading /), e.g. /Users/alice/my_api → ~/.claude/projects/-Users-alice-my-api/. Chronicle reads that tree but does not copy the dash into its own: it stores under the de-dashed storage key, ~/.chronicle/projects/Users-alice-my-api/ — Chronicle's folders never start with a dash. The two map one-to-one (the source slug is just - + the storage key).
Project matching — --project <name> (and chronicle query show <name>) accepts whatever name a command shows you: the folder basename (my_api), the de-dashed key (Users-alice-my-api), or any substring of either, with punctuation normalized — so --project my_api resolves even though the key stores my-api. Lists (projects, timeline) show the de-dashed key to disambiguate same-named folders; single-project views show the basename. List everything with chronicle query projects.
Marker state — each session is in exactly one state: unprocessed (no marker), success (.processed/<hash>), or failed (.failed/<hash>.json with terminal flag + attempt counter). See State and failures.
The configured Claude Code hook events (SessionStart, UserPromptSubmit, Stop, and SessionEnd) fire chronicle-hook (the same binary, dispatched by argv[0] via _entrypoint.py). Every configured event appends a line to events.jsonl; only SessionStart does anything extra.
Entries are installed in Claude Code's exec form — args is present, so command is spawned directly with no sh -c layer:
{ "type": "command", "command": "/Users/you/.local/bin/chronicle-hook", "args": [] }Upgrading from v0.12.3 or earlier — one manual step. Those releases wrote a shell-form entry (
{"type": "command", "command": "chronicle-hook"}, noargs). Chronicle no longer recognizes that shape, so upgrading leaves it in~/.claude/settings.jsonand adds the exec-form entry above. Until you delete the old entry by hand,chronicle-hookruns twice per event, andchronicle uninstallwill not remove it. Open~/.claude/settings.jsonand delete every hook entry that has noargskey and whosecommandends inchronicle-hook— it may be the bare name, or an absolute path you edited in yourself.chronicle doctorcounts only exec-form entries and will not flag the duplicate.
Two consequences worth knowing:
- The path must be absolute and correct. Exec form has no shell and therefore no PATH fallback, so a wrong path is a silently dead hook rather than a visible error.
install.shpasses the path it is about to create via$CHRONICLE_HOOK_PATH; a standalonechronicle install-hooksresolves the realchronicle-hook(frozen-binary sibling →PATH→~/.local/bin) and warns if the result does not exist. This is what makes hooks work when Claude Code is launched from the macOS GUI or an IDE rather than a terminal, where a barechronicle-hookwould not be on the inherited PATH. matcheris omitted, which the hooks reference defines as "match all" — correct for lifecycle events that should always fire.
flowchart TB
CC["Claude Code<br/>(hook event JSON on stdin)"] --> HOOK["chronicle-hook"]
HOOK --> APPEND[("append event to<br/>~/.chronicle/events.jsonl<br/><b>always, for every event</b>")]
APPEND --> EVT{"hook_event_name"}
EVT -->|"UserPromptSubmit"| RET([exit 0])
EVT -->|"Stop"| RET
EVT -->|"SessionEnd"| RET
EVT -->|"SessionStart"| SS_CTX
subgraph SS_CTX["SessionStart: inject past-session context"]
direction TB
LOAD["slug = project_slug_for(cwd,<br/>transcript_path)<br/>load_recent_titles(slug, max=10)"]
HAS{"titles ?"}
EMIT["print JSON on stdout:<br/>hookSpecificOutput.additionalContext<br/>= 'Previous sessions: …'"]
NOEMIT["no stdout<br/>(no empty-context noise)"]
LOAD --> HAS
HAS -->|yes| EMIT
HAS -->|no| NOEMIT
end
subgraph SS_DAEMON["SessionStart: self-heal daemon (bg mode only)"]
direction TB
BG{"processing_mode<br/>== background ?"}
ALIVE{"_daemon_running()<br/>via daemon.pid flock<br/>authoritative probe ?"}
RESP["_spawn_daemon()<br/>argv = _spawn_daemon_cmd()<br/>• frozen → [chronicle, daemon]<br/>• dev → [python, -m, chronicle.daemon]"]
NOOP["skip"]
FG["NEVER spawn —<br/>foreground = zero<br/>passive token burn"]
BG -->|yes| ALIVE
ALIVE -->|no| RESP
ALIVE -->|yes| NOOP
BG -->|no| FG
end
EVT -.->|SessionStart also runs| SS_DAEMON
SS_CTX --> RET
SS_DAEMON --> RET
ERR["any exception?<br/>trap + log to<br/>~/.chronicle/hook-errors.log"] -.->|never raise —<br/>must not block the session| RET
The service manager's own respawn (launchd
KeepAlive/ systemdRestart=on-failure) is the primary recovery path._spawn_daemonis defense-in-depth for the window between a daemon crash and the service manager noticing.
Both foreground and background use the same pipeline. The only difference is who triggers it.
flowchart TB
subgraph hooks["Hooks (always)"]
SS["SessionStart: inject past titles<br/>(+ spawn daemon if background)"]
LOG["UserPromptSubmit / Stop / SessionEnd:<br/>append to ~/.chronicle/events.jsonl"]
end
TRIG{"Trigger?"}
subgraph fg["Foreground (explicit)"]
FG["chronicle process / insight /<br/>story / rewind --summary"]
end
subgraph bg["Background (daemon)"]
BG["Debounce 5 min quiet<br/>+ periodic scan"]
end
subgraph pipeline["claude_cli.spawn_claude"]
RESOLVE["resolve claude binary<br/>(shutil.which + fallback dirs)"]
ENV["strip ANTHROPIC_API_KEY /<br/>AUTH_TOKEN / BASE_URL"]
SPAWN["claude --safe-mode -p --tools ''<br/>--output-format json<br/>--no-session-persistence<br/>(--json-schema for session summaries;<br/>model/effort/fallback unset unless configured;<br/>no timeout)"]
CLASSIFY["classify result:<br/>INFRA / TRANSIENT / PARSE /<br/>CONTEXT / STRUCTURED_OUTPUT"]
end
subgraph write["Write (under processing.lock)"]
OK[".processed/<hash><br/>+ sessions/*.md<br/>+ chronicle.md"]
FAIL[".failed/<hash>.json<br/>{attempts, terminal, error}"]
end
CC["Claude Code session"] --> hooks
hooks --> TRIG
TRIG -->|"user ran a command"| FG
TRIG -->|"daemon tick + quiet window"| BG
FG --> RESOLVE
BG --> RESOLVE
RESOLVE --> ENV --> SPAWN --> CLASSIFY
CLASSIFY -->|success| OK
CLASSIFY -->|transient / parse| FAIL
CLASSIFY -->|context / structured_output| TERM[".failed/<hash>.json<br/>terminal=true (not retried)"]
CLASSIFY -.->|INFRA| config_fix["user fixes PATH / auth;<br/>no retry budget consumed"]
Five-step invariant on every summarization (foreground or background):
- Extract the session JSONL in full (no Chronicle size cap), redact secrets (API keys, tokens, JWTs, connection URIs,
.env/.pem/.keycontents). - Resolve the
claudebinary; build a subprocess env with auth-routing vars stripped. - Invoke
claude -punder the processing lock (~/.chronicle/processing.lock) with no wall-clock timeout — it runs as long as claude needs and stays Ctrl-C/SIGTERM interruptible. Prompts above the Claude CLI's 10 MiB stdin cap are classified as terminal context failures before spawning. - Classify the outcome: success / transient / parse / infra / context / structured_output.
- Write
.processed/(success) or.failed/(transient → terminal aftermax_retries; context/structured_output → terminal immediately).
Only relevant if you chronicle install-daemon.
- Debounce. The daemon waits until ALL sessions across ALL projects have been quiet for
quiet_minutes(default 5) before processing anything. This prevents the daemon from competing with your active coding session for the same subscription rate limits. - Periodic scan. Every
scan_interval_minutes(default 30) the daemon walks~/.claude/projects/and queues any session JSONL that has no.processedor.failedmarker — picks up sessions that pre-date the install or were missed while the daemon was down. - Parallel workers. Up to
concurrency(default 5) summarizations run concurrently viaasyncio.Semaphore. Each worker is an independentclaude -psubprocess. - Singleton. Single daemon enforced by
fcntl.flockon~/.chronicle/daemon.pidplus inode-validation to detect PID-file replacement. - Graceful shutdown. On SIGTERM/SIGINT/SIGHUP, the daemon terminates in-flight
claudesubprocesses (SIGTERM then SIGKILL after 5s) before exiting. - Service-manager-aware batch. In background mode,
chronicle processpauses the service (launchctl bootout/systemctl --user stop) and holds the processing lock, then resumes after. In foreground mode the pause step is a no-op and only the processing lock is taken. - Self-disable. If config says foreground but the service respawned the daemon anyway, the daemon idles instead of exiting — avoids a KeepAlive restart loop.
Two fcntl.flock locks cover every way sessions can get summarized. Both are released automatically when the owning process exits — crashes don't wedge anything.
flowchart TB
subgraph locks["fcntl locks (released on process exit)"]
direction LR
L1[("daemon.pid<br/><b>singleton:</b> at most one daemon")]
L2[("processing.lock<br/><b>mutex:</b> daemon XOR chronicle process")]
end
subgraph daemon_side["daemon.py (background only)"]
direction TB
D1["acquire_daemon_lock()<br/>LOCK_EX | LOCK_NB"]
D2["inode check each loop tick"]
D3["non-blocking acquire of<br/>processing.lock before<br/>each debounced batch"]
D4{"lock still on<br/>same inode ?"}
D1 --> D2 --> D3
D2 -.-> D4
D4 -->|no| BAIL["another daemon took over<br/>(PID file replaced)<br/>→ exit cleanly"]
end
subgraph batch_side["batch.py (chronicle process)"]
direction TB
B1["is_background_mode() ?<br/>→ pause_service()<br/>(launchctl bootout /<br/>systemctl stop)"]
B2["<b>blocking</b> acquire of<br/>processing.lock<br/>(waits out an in-flight<br/>daemon batch)"]
B3["summarize workers<br/>(parallel, semaphore)"]
B4["release lock<br/>finally: resume_service()"]
B1 --> B2 --> B3 --> B4
end
D1 --> L1
D3 --> L2
B2 --> L2
note["<b>Invariants</b><br/>• at most 1 daemon per host (L1)<br/>• at most 1 summarizer holder (L2)<br/>• service pause is hygiene;<br/> processing.lock is correctness"]
stateDiagram-v2
[*] --> Unprocessed: new JSONL in ~/.claude/projects/
Unprocessed --> Success: claude -p ok
Unprocessed --> Retriable: transient / parse error
Unprocessed --> Terminal: context / structured_output error
Retriable --> Success: retry ok
Retriable --> Retriable: another transient
Retriable --> Terminal: attempts == max_retries (if set)
Success: .processed/<hash><br/>+ sessions/*.md
Retriable: .failed/<hash>.json<br/>terminal=false<br/>(max_retries=null → retry forever)
Terminal: .failed/<hash>.json<br/>terminal=true
Terminal --> Unprocessed: chronicle process --retry-failed
Success --> Unprocessed: chronicle process --force
Infra errors don't enter this state machine. A missing claude binary, auth failure, or permission error is a daemon-level problem, not a per-session one — no marker is written, no retry budget consumed.
flowchart LR
CALL["spawn_claude()"] --> RC{"returncode<br/>== 0 ?"}
RC -- no --> ERRSNIFF{"stderr has<br/>auth / no-such-file?"}
ERRSNIFF -- yes --> INFRA["ErrorKind.INFRA<br/>(NOT counted —<br/>user fixes config)"]
ERRSNIFF -- no --> TRANS
RC -- yes --> PARSE{"outer JSON<br/>parseable ?"}
PARSE -- no --> PERR["ErrorKind.PARSE"]
PARSE -- yes --> ISERR{"outer.is_error ?"}
ISERR -- yes --> SNIFF{"message =<br/>context / schema-retries ?"}
SNIFF -- yes --> TERM["ErrorKind.CONTEXT /<br/>STRUCTURED_OUTPUT<br/>(terminal — not retried)"]
SNIFF -- no --> TRANS["ErrorKind.TRANSIENT"]
ISERR -- no --> OK["Success"]
PERR --> COUNT["counts against max_retries<br/>(null = unlimited)"]
TRANS --> COUNT
To inspect current failure state: chronicle doctor (or chronicle doctor --json). To retry after fixing the underlying issue: chronicle process --retry-failed --workers 5.
Each project gets up to three views:
| Output | What it is | How to access |
|---|---|---|
chronicle.md |
Cumulative session records per project | chronicle query sessions |
insight.html |
LLM-generated HTML dashboard with charts + narrative | chronicle insight [project] |
story.md |
Unified chronological project narrative | chronicle story [project] |
Default paths below use ~/.chronicle/; set CHRONICLE_HOME to move Chronicle's state and runtime root.
~/.claude/projects/-<slug>/*.jsonl # Claude Code session transcripts (source; dir starts with '-')
~/.chronicle/
events.jsonl # hook event journal
events.offset # daemon read position (background only)
config.json # processing_mode + model + concurrency + …
daemon.pid # singleton lock (background only)
daemon.log # daemon stdout/stderr (background only)
processing.lock # mutex between daemon and `chronicle process`
runtime/ # unpacked PyInstaller binary (`chronicle update` swaps this atomically)
.processed/<hash> # success marker
.failed/<hash>.json # failure record (attempts, terminal, error)
projects/<key>/ # de-dashed storage key (NO leading dash; key == slug without the '-')
chronicle.md # cumulative project log
insight.html # `chronicle insight` output
story.md # `chronicle story` output
sessions/
2026-04-01_0611_abc12345_title.md # per-session record
~/Library/LaunchAgents/com.chronicle.daemon.plist # macOS service (background only)
~/.config/systemd/user/chronicle-daemon.service # Linux service (background only)
LLM-structured fields: decisions with status + rationale + alternatives · problems solved (symptom/diagnosis/fix/verification) · developer reasoning moments · follow-up questions · architecture patterns · planning evolution · technical details (stack, errors, commands, config) · notable activity for Agent Teams/tasks/workflows/MCP/new tools · tags and unknown structured extras · per-session cost.
Full, untruncated archive. A chronicle is a complete record — every tool call's full input and full output is kept verbatim, no matter how large (full Bash commands, complete Edit/Write/MultiEdit diffs, entire subagent prompts, whole MCP payloads, complete tool output). Nothing is clipped. Secret redaction still runs on every captured field before anything is written.
Navigable, collapsible layout. Because that archive is huge, the turn-by-turn log is organized for scanning in both vim (heading folds) and rendered Markdown (GitHub/VS Code <details>):
- A
### Turn index— one compact, always-visible line per turn (`T042` · 10:03 · ASSISTANT — Bash, Edit×2) for fast scanning. - A
### Full chronological log— a single collapsed<details>holding every turn in full. Message text is fenced verbatim; each tool input/output is its own collapsed<details>leaf. Per-turn labels are plain text (not headings), so a 446-turn session keeps a clean ~7-entry document outline instead of polluting it. - Dynamically-sized backtick fences (always longer than any backtick run inside) mean embedded code fences — and verbatim content that happens to contain Chronicle's own structural markers — can never corrupt the Markdown or the document structure.
The only compact-by-design surface is the per-project chronicle.md timeline table (a navigational index whose full title/summary live in the detail below). Every byte of actual history is retained in full.
~/.chronicle/config.json (auto-created):
| Key | Default | Scope | Description |
|---|---|---|---|
processing_mode |
"foreground" |
both | "foreground" or "background". Set via chronicle install-daemon / uninstall-daemon. |
model |
null |
both | Summarization model. null ⇒ let claude -p use its own default (auto-follows Claude Code). Set e.g. "opus[1m]" for the 1M-context window on very large sessions. |
effort |
null |
both | Reasoning effort passed to --effort. null ⇒ claude's default. |
fallback_model |
null |
both | --fallback-model (sent only when set). null avoids silently dropping to a smaller-context model. |
max_retries |
null |
both | Transient/parse failures flip terminal after N attempts. null ⇒ unlimited (never give up). Context-window and structured-output failures are always terminal immediately. |
skip_projects |
[] |
both | Project slugs (substrings) to exclude. |
concurrency |
5 |
background | Parallel workers in the daemon. (chronicle process --workers overrides for that invocation.) |
poll_interval_seconds |
5 |
background | Daemon event-journal poll cadence. |
quiet_minutes |
5 |
background | Debounce — minutes of silence before daemon processes. |
scan_interval_minutes |
30 |
background | How often the daemon scans for non-terminal sessions missed by hook events. |
- Secret redaction. User/assistant prose, tool commands, selected tool inputs, tool outputs, and summarization error messages pass through a pattern scanner before any markdown or marker detail is written. API keys (
sk-,ghp_,AKIA,xoxb-), auth headers (Bearer …), private keys (-----BEGIN …), JWTs (eyJ…), connection URIs (postgres://user:pass@…), and env-var assignments (API_KEY=…,SECRET=…) are replaced with[REDACTED]..env,.pem, and.keyfile content is fully redacted. - Subscription routing. Every
claude -psubprocess call stripsANTHROPIC_API_KEY,ANTHROPIC_AUTH_TOKEN, andANTHROPIC_BASE_URLfrom the environment — summarization always routes through your Claude.ai subscription, never API credits or a proxy gateway (anthropics/claude-code#2051). - File permissions.
~/.chronicle/is0700(owner-only), matching~/.claude/. - Isolated summarization. Every
claude -pChronicle spawns runs with--safe-modeand--tools "". Safe mode disables all customizations — yourCLAUDE.md, skills, plugins, hooks, MCP servers, custom agents, and output styles — while auth, model selection, and permissions work normally (this is why--safe-modeis used rather than--bare, which skips OAuth/keychain and demands API credentials). Two things follow. First, Chronicle's own hooks no longer fire inside Chronicle's own subprocess: without safe mode each summarization triggeredchronicle-hookfour times, writing junk events intoevents.jsonland injecting the wrong project's session titles into the summarizer's prompt. Second, your customizations do not shape Chronicle's output — if you previously relied onCLAUDE.mdor an output style to steerstory.mdorinsight.html, that influence is gone by design. Disabling built-in tools also means a prompt injection carried inside a transcript has no file or command access to reach for. Note this is isolation, not determinism: model sampling and Claude Code's own defaults still vary. - Observer-only at runtime. Chronicle never writes to
~/.claude/projects/(the session transcripts), never blocks a hook, never modifies Claude Code behavior. The only effect on an active session is theadditionalContextinjection of past session titles on SessionStart. All deletion operations (rewind --delete,--prune) only touch chronicle's own markdown and markers — the original JSONL in~/.claude/projects/stays. Install / uninstall are the one exception:install-hooksanduninstalldo edit~/.claude/settings.jsonto add or remove thechronicle-hookentries, and they preserve any unrelated hook entries already there.
Run chronicle doctor first. It reports:
- Resolved
claudebinary path (or flags it as missing) - Effective PATH
- Current mode + daemon status + service drift warnings
- Processing lock state
- Aggregate processed / pending / terminal-failure counts and marker totals
- Integration state for symlinks, runtime binary, and hook entries
Common fixes:
claudenot found. Install the Claude Code CLI, or ensure it's on the daemon's PATH.chronicle install-daemonbakes PATH into the launchd plist / systemd unit so minimal service-manager envs (/usr/bin:/bin:/usr/sbin:/sbin) don't causeFileNotFoundError.chroniclenot found, but runtime exists. Checkls -l ~/.local/bin/chronicle ~/.chronicle/runtime/chronicle. If the runtime binary exists but the~/.local/bin/chroniclesymlink is missing, reruninstall.sh(orchronicle updateif the command still resolves anywhere) to restore it.codex-chronicleis installed too. That is not a name collision.codex-chronicleandchronicleare separate commands with separate install roots.- Mode drift warning. Config says one mode but service state says another.
chronicle install-daemon/uninstall-daemonreconciles. - Terminal failures after fixing a config issue.
chronicle process --retry-failed --workers 5. - Ubuntu background mode survives logout. Run once:
sudo loginctl enable-linger "$USER". - Scripted health check.
chronicle doctor --jsonemits a schema-versioned document with a top-levelok: bool; exit code is 0 if healthy, 1 if any of: drift detected,claudebinary unresolved, orconfig.jsonunreadable.
chronicle/
__main__.py # CLI dispatcher (process / query / rewind / insight /
# story / doctor / install-daemon / uninstall-daemon /
# daemon / install-hooks / update / uninstall)
_entrypoint.py # PyInstaller busybox dispatcher — argv[0] picks
# between chronicle CLI and chronicle-hook
hook.py # hook dispatcher — logs events, injects context,
# spawns daemon (background only)
daemon.py # background poll loop, debounce, scan, parallel workers
batch.py # `chronicle process` — service-manager-aware batch
summarizer.py # build prompt + parse structured_output → ChronicleEntry
extractor.py # JSONL → SessionDigest + timeline (with secret redaction)
storage.py # marker layout (.processed, .failed) + chronicle.md writes
filtering.py # should_skip: success / terminal / skip-project / self
query.py # query projects / timeline / sessions / search
rewind.py # numbered navigator — view, diff, summarize, delete, prune
insight.py # LLM-generated HTML dashboard
story.py # LLM-generated unified narrative
doctor.py # diagnostic (text + --json)
claude_cli.py # resolve claude binary, env sanitization, spawn wrapper,
# error classification, subprocess registry
service.py # launchd plist / systemd unit install / pause / resume,
# mode-drift detection
locks.py # fcntl helpers: singleton daemon lock + processing mutex
mode.py # processing_mode get/set (config is authoritative)
config.py # paths + defaults
install_hooks.py # idempotent ~/.claude/settings.json hook merge
Tests: tests/unit/ (per-module) + tests/functional/ (subprocess-level end-to-end with a fake claude stub). Runs in a few seconds — see pytest -q for the current count.
End users never need Python — the released artifact is a self-contained PyInstaller binary. Contributors do: Chronicle targets Python 3.14 (the version the release workflow builds and tests against; requires-python = ">=3.14").
python3.14 -m venv .venv && source .venv/bin/activate
pip install -e ".[dev]" # editable install + pytest
pytest -q # run the suite
python -m chronicle --help # run the CLI from source