
Fireworks Sessions Saver
- 2 installs
- 8 repo stars
- Updated May 2, 2026
- yizhiyanhua-ai/fireworks-sessions-saver
Persists and recovers Claude Code session state, providing checkpoints, crash recovery, and a session dashboard.
About
Provides session state persistence and recovery for Claude Code, including context recovery, progress saving, checkpoints, and a dashboard. A developer uses it to restore work after a crash or resume a prior session.
- Checkpoint and crash-recovery for Claude Code sessions
- Session dashboard for saved state
Fireworks Sessions Saver by the numbers
- 2 all-time installs (skills.sh)
- Ranked #2,419 of 3,282 Productivity & Planning skills by installs in the Skillselion catalog
- Data as of Jul 28, 2026 (Skillselion catalog sync)
npx skills add https://github.com/yizhiyanhua-ai/fireworks-sessions-saver --skill fireworks-sessions-saverAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 2 |
|---|---|
| repo stars | ★ 8 |
| Last updated | May 2, 2026 |
| Repository | yizhiyanhua-ai/fireworks-sessions-saver ↗ |
What it does
Persists and recovers Claude Code session state, providing checkpoints, crash recovery, and a session dashboard.
Files
fireworks-sessions-saver
Never lose your coding session context again. Auto-persists and restores session state for Claude Code.
What It Does
Network timeouts, crashes, and accidental window closures kill your session context. fireworks-sessions-saver automatically tracks what you're working on and makes it instantly recoverable.
1. Auto-tracking — heartbeat hook updates last_active after every file write or command 2. Rich checkpoints — capture task, decisions, files, and open questions on demand 3. One-keystroke restore — recover full context in a new session in seconds 4. Multi-project dashboard — see all active sessions across directories 5. Checkpoint diff — compare what changed between saves
Installation
Quick Install
In Claude Code, say:
"Help me install fireworks-sessions-saver from https://github.com/yizhiyanhua-ai/fireworks-sessions-saver"
Or run:
curl -fsSL https://raw.githubusercontent.com/yizhiyanhua-ai/fireworks-sessions-saver/main/install.sh | bashnpx skills Install
npx skills add yizhiyanhua-ai/fireworks-sessions-saver -gHow It Works
A single PostToolUse hook fires heartbeat.py asynchronously after every Write, Edit, or Bash call — updating last_active and tracking modified files.
| Component | Purpose |
|---|---|
heartbeat.py | Lightweight async hook — tracks activity after tool calls |
save_session.py | Create rich checkpoints (init / checkpoint / cleanup) |
list_sessions.py | Scan for recoverable sessions in current directory |
restore_session.py | Print structured context summary from archive |
dashboard.py | View all active sessions across directories |
diff_session.py | Compare changes between checkpoints |
Storage
~/.claude/sessions/
active_{workdir_hash}.json ← current session (rolling 10 checkpoints)
archive_{workdir_hash}_{ts}.json ← archived (restored or expired after 7 days)Only file paths are stored — never file contents.
Requirements
- Python 3.9+
- Claude Code CLI
- macOS / Linux
More Information
- Full Documentation
- 中文文档
- Report Bug
你的 AI 编程助手记性有多差?聊聊会话上下文丢失这个被忽视的效率黑洞
每次打开新窗口,都要重新跟 AI 解释一遍"我们在做什么"——这件事你习惯了吗?
---
一、AI 编程工具爆发,但有一个问题没人认真解决
过去两年,AI 编程工具的渗透速度超出所有人的预期。Claude Code、Codex、Cursor、Copilot……这些工具已经不是"辅助"了,对很多开发者来说,它们是真正的协作者。你跟它讲需求,它帮你写代码、找 bug、做重构,效率提升是实实在在的。
但有一个问题,几乎所有人都遇到过,却很少被认真讨论:
会话上下文丢失。
你正在做一个复杂的 auth 重构,跟 AI 聊了两个小时,建立了完整的上下文——哪些文件在改、为什么这样设计、还有哪些问题没解决。然后网络断了。或者你不小心关了窗口。或者电脑睡眠后 session 超时了。
重新打开,一切归零。
你要重新解释项目背景,重新描述当前任务,重新告诉 AI 哪些文件是关键的。这个过程少则五分钟,多则二十分钟。而且你还得祈祷自己没漏掉什么重要的上下文。
这不是小问题。这是一个每天都在发生的效率黑洞。
!痛点场景:会话上下文丢失
---
二、这个痛点到底有多痛?
我们来量化一下这件事的成本。
假设你每天用 Claude Code 工作 4 小时,平均每天遇到 2 次会话中断(断网、重启、误关窗口)。每次重建上下文需要 10 分钟。
每天损失:20 分钟 每月损失:约 7 小时 每年损失:约 84 小时
这还只是时间成本。更隐性的成本是:
认知切换成本。 重建上下文不只是"再说一遍",你需要重新进入那个思维状态,回忆当时的决策逻辑,找回那种"在状态里"的感觉。心理学研究表明,深度工作被打断后,平均需要 23 分钟才能完全恢复专注。
信息损耗。 你能记住所有的上下文吗?那个"暂时先这样,后面再改"的决定,那个"这个 API 有个坑要注意"的备注,那个还没解决的边界情况——这些细节很容易在重建过程中丢失。
Token 浪费。 每次重建上下文,你都在消耗 token 来"喂"AI 背景信息。这些 token 本可以用在真正的工作上。
更糟糕的是,随着 AI 编程工具越来越强大,单次会话的深度也在增加。以前可能聊 20 分钟就能完成一个任务,现在一个复杂任务可能需要持续几个小时的深度协作。会话越深,中断的代价越大。
---
三、大家是怎么"解决"这个问题的?
说"解决"其实不准确,大多数人只是在"应对"。
方案一:手动记笔记。 在 Notion 或者备忘录里记下当前任务、关键决策、待解决问题。听起来不错,但实际执行率极低——你在专注编程的时候,很少会想到停下来记笔记。而且记笔记本身也是一种上下文切换。
方案二:在对话框里粘贴背景。 新窗口打开,把上次的关键信息复制粘贴进去。这个方法有效,但繁琐,而且你需要提前知道"哪些信息是关键的"——而这恰恰是你在深度工作状态下才能判断的事情。
方案三:不关窗口。 让 session 一直开着,永远不关。这在实际工作中不现实,而且解决不了断网和崩溃的问题。
方案四:接受现实。 大多数人最终选择了这个"方案"。每次重建上下文,当作热身运动。
这些方案的共同问题是:它们都依赖人的主动行为。而人在专注工作时,最不擅长的就是主动做这些"元工作"。
真正好的解决方案应该是:自动的、无感知的、在你需要的时候随时可用的。
---
四、正确的解法:自动持久化 + 结构化恢复
想清楚这个问题的本质,解法其实并不复杂。
我们需要的是一个系统,它能:
1. 自动追踪:不需要你主动触发,在你工作的过程中静默记录状态 2. 结构化存储:不只是保存聊天记录,而是提取关键信息——当前任务、涉及文件、关键决策、未解决问题 3. 智能恢复:新会话开始时,自动检测历史状态,一键恢复上下文 4. 零干扰:整个过程对工作流没有任何影响
这个思路的关键洞察是:AI 编程工具本身就有 hook 机制。每次文件写入、代码执行,都会触发事件。我们可以利用这些事件,在后台静默地更新会话状态。
这就是 fireworks-sessions-saver 的核心设计思路。
---
五、fireworks-sessions-saver:把会话状态变成持久资产
fireworks-sessions-saver 是一个专为 Claude Code 设计的会话状态持久化工具,开源在 GitHub 上。
它的设计哲学很简单:会话上下文是有价值的资产,不应该因为技术原因丢失。
架构设计
整个系统分为两条链路:
!系统架构图
追踪链路(左侧): 通过 Claude Code 的 PostToolUse hook,每次 Write、Edit、Bash 调用都会异步触发 heartbeat.py,耗时不超过 5ms。它做两件事:更新 last_active 时间戳,以及将 git 修改的文件路径合并进 session 文件。当你说"保存进度"时,会写入一个完整的 checkpoint,记录当前任务、关键决策、未解决问题、文件引用、git 分支和日志路径。
恢复链路(右侧): 新 session 启动时,list_sessions.py 自动扫描同一工作目录下 7 天内有活动的历史 session。用户选择后,restore_session.py 输出结构化上下文摘要。新 session 保存第一个 checkpoint 后,归档文件自动删除,不会积累冗余文件。
组件结构
!组件图
四层架构,职责清晰:
- CLI 工具层:Claude Code 是主要支持目标,架构对其他 coding CLI 开放扩展
- Hook 层:
settings.json中一条PostToolUsehook,异步触发,零影响 - 脚本层:四个职责单一的 Python 脚本,每个只做一件事
- 存储层:
~/.claude/sessions/下的 JSON 文件,只存路径,不存内容
使用方式
安装极其简单,在 Claude Code 对话框里说一句话:
"从 https://github.com/yizhiyanhua-ai/fireworks-sessions-saver 安装 fireworks-sessions-saver"
安装完成后,一切自动运行。你不需要改变任何工作习惯。
想保存一个完整的 checkpoint?说"保存进度"。 新窗口想恢复上次的状态?说"恢复会话"。
就这么简单。
---
六、最新功能:多项目看板 + Checkpoint Diff
最近刚上线了两个新功能,解决了多窗口工作场景下的痛点。
!新功能展示:Dashboard 和 Diff
多项目看板(Dashboard)
如果你同时开着多个项目的 Claude Code 窗口,现在可以一眼看清所有活跃 session 的状态:
SESSION DASHBOARD — 3 session(s) found
ACTIVE (3)
[1] 2026-04-08 22:49 (just now) — claude-code
Dir: /projects/auth-service
Task: 重构 JWT 中间件
CPs: 3 · Files: 5
[2] 2026-04-08 21:30 (1h ago) — claude-code
Dir: /projects/frontend
Task: 修复登录页面样式问题
CPs: 1 · Files: 2说"查看所有 session"或"多项目看板"即可触发。
Checkpoint Diff
想知道从上次保存到现在,到底改了什么?Checkpoint diff 功能可以精确对比任意两个 checkpoint 之间的变化:
CHECKPOINT DIFF [2] → [3]
── Task ──────────────────────────────────────
- 分析现有 auth 架构
+ 实现 JWT refresh token 逻辑
── Files ─────────────────────────────────────
+ src/auth/refresh.ts [created]
~ src/auth/middleware.ts reference → editing
── Key Decisions ─────────────────────────────
+ refresh token 存储在 httpOnly cookie 中说"对比进度"或"checkpoint 差异"触发。
---
七、一些设计细节值得关注
为什么用 JSON 而不是数据库?
轻量、可读、无依赖。session 文件可以直接用文本编辑器打开查看,也方便调试和手动修改。
为什么只存路径不存内容?
隐私和安全。你的代码内容不应该被存储在一个额外的地方。路径已经足够让 AI 在恢复时重新读取文件内容。
7 天过期策略是怎么考虑的?
超过 7 天的 session,上下文的时效性已经很低了。代码可能已经大幅变化,继续恢复反而会引入混乱。7 天是一个经验值,在实际使用中覆盖了绝大多数"我昨天/上周在做什么"的场景。
SessionStart hook 的价值
最新版本加入了 SessionStart hook,每个新窗口启动时自动 init session。这意味着你不需要手动触发任何操作,所有窗口都会被自动追踪,dashboard 里能看到完整的多项目视图。
---
八、写在最后
AI 编程工具正在快速进化,但有些基础设施问题还没有被认真对待。会话上下文持久化就是其中之一。
这不是一个很性感的功能,但它每天都在影响你的工作效率。就像版本控制一样——在有 git 之前,大家也都"活下来了",但有了 git 之后,你很难想象没有它的工作方式。
fireworks-sessions-saver 现在还很早期,但核心机制已经稳定可用。如果你每天都在用 Claude Code,值得花五分钟试一试。
项目地址:https://github.com/yizhiyanhua-ai/fireworks-sessions-saver
欢迎 PR,也欢迎在 issue 里聊聊你遇到的会话上下文问题。
---
Python 3.9+ · macOS/Linux · MIT License
{
"jobs": 2,
"tasks": [
{
"id": "context-loss",
"promptFiles": ["prompts/01-scene-context-loss.md"],
"image": "01-scene-context-loss.png",
"provider": "google",
"model": "gemini-3-pro-image-preview",
"ar": "16:9",
"quality": "2k"
},
{
"id": "new-features",
"promptFiles": ["prompts/02-infographic-new-features.md"],
"image": "02-infographic-new-features.png",
"provider": "google",
"model": "gemini-3-pro-image-preview",
"ar": "16:9",
"quality": "2k"
}
]
}
ZONES
LEFT ZONE (40%): Terminal window showing Claude Code session mid-conversation, code visible, task in progress label "JWT auth refactor — 2hrs deep" CENTER ZONE (20%): Large broken chain icon or lightning bolt, red warning glow, text "CONNECTION LOST" RIGHT ZONE (40%): Empty new terminal window, blank chat, sad empty state, label "Start over..."
LABELS
- Top left badge: "Session 1 — 2h of context"
- Bottom left: "auth/middleware.ts · jwt.ts · 5 key decisions"
- Center warning: "Network timeout"
- Right label: "New session — context: zero"
- Bottom right: "~20 min to rebuild"
COLORS
Background: #0f0f1a → #1a1a2e gradient Left panel: #1e3a5f border #3b82f6 (active, glowing) Center: #7f1d1d border #ef4444 (danger red) Right panel: #1e293b border #334155 (dark, empty) Text primary: #e2e8f0 Text secondary: #94a3b8 Warning accent: #f97316
STYLE
Dark terminal aesthetic, monospace font SF Mono / Fira Code, subtle grid lines in background, clean minimal layout, no gradients on text, professional tech illustration
ZONES
TOP ZONE (15%): Title bar "fireworks-sessions-saver — New Features v1.1" LEFT HALF (42%): Dashboard panel showing multi-project session list RIGHT HALF (42%): Diff panel showing checkpoint comparison CENTER DIVIDER (6%): Vertical separator with "+" icon
LABELS
Left panel title: "📊 Multi-Project Dashboard" Left panel content: "SESSION DASHBOARD — 3 active" "[1] /projects/auth-service 3 CPs" " Task: JWT middleware refactor" "[2] /projects/frontend 1 CP" " Task: Fix login page styles" "[3] /projects/api 2 CPs" " Task: Rate limiting impl" Left trigger: 'Say: "查看所有 session"'
Right panel title: "🔍 Checkpoint Diff" Right panel content: "DIFF [2] → [3]" "── Task ──────────────" " - Analyze auth arch" " + Implement JWT refresh" "── Files ─────────────" " + src/auth/refresh.ts" " ~ middleware.ts → editing" "── Decisions ─────────" " + httpOnly cookie storage" Right trigger: 'Say: "对比进度"'
COLORS
Background: #0f0f1a → #1a1a2e gradient Left panel: #0a1a0f border #10b981 (green) Right panel: #1a0a2e border #a855f7 (purple) Title bar: #0f172a border #334155 Added lines (+): #10b981 Removed lines (-): #ef4444 Changed lines (~): #f97316 Text primary: #e2e8f0 Text secondary: #94a3b8 Trigger badges: #1e3a5f border #3b82f6
STYLE
Dark terminal aesthetic, monospace font SF Mono / Fira Code, code-editor look, clean grid layout, subtle glow on panel borders, professional tech illustration
Contributor Covenant Code of Conduct
Our Pledge
We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, caste, color, religion, or sexual identity and orientation.
Our Standards
Examples of behavior that contributes to a positive environment:
- Demonstrating empathy and kindness toward other people
- Being respectful of differing opinions, viewpoints, and experiences
- Giving and gracefully accepting constructive feedback
- Accepting responsibility and apologizing to those affected by our mistakes
- Focusing on what is best not just for us as individuals, but for the overall community
Examples of unacceptable behavior:
- The use of sexualized language or imagery, and sexual attention or advances of any kind
- Trolling, insulting or derogatory comments, and personal or political attacks
- Public or private harassment
- Publishing others' private information without their explicit permission
- Other conduct which could reasonably be considered inappropriate in a professional setting
Enforcement
Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful.
Attribution
This Code of Conduct is adapted from the Contributor Covenant, version 2.1.
#!/usr/bin/env bash
# fireworks-sessions-saver — one-command installer
# Usage: curl -fsSL https://raw.githubusercontent.com/yizhiyanhua-ai/fireworks-sessions-saver/main/install.sh | bash
set -euo pipefail
REPO="https://raw.githubusercontent.com/yizhiyanhua-ai/fireworks-sessions-saver/main"
SKILL_DIR="$HOME/.claude/skills/fireworks-sessions-saver"
SETTINGS="$HOME/.claude/settings.json"
GREEN='\033[0;32m'; YELLOW='\033[1;33m'; RED='\033[0;31m'; NC='\033[0m'
info() { echo -e "${GREEN}✓${NC} $*"; }
warn() { echo -e "${YELLOW}⚠${NC} $*"; }
error() { echo -e "${RED}✗${NC} $*"; exit 1; }
echo ""
echo "🔥 fireworks-sessions-saver installer"
echo "──────────────────────────────────────"
echo ""
# ── 1. Preflight ───────────────────────────────────────────────────────────────
command -v python3 >/dev/null 2>&1 || error "Python 3 is required but not found."
command -v claude >/dev/null 2>&1 || error "Claude Code CLI not found. Install from https://claude.ai/code"
PY_MINOR=$(python3 -c "import sys; print(sys.version_info.minor)")
[[ "$PY_MINOR" -lt 9 ]] && error "Python 3.9+ required (found 3.$PY_MINOR)"
info "Python $(python3 --version) found"
info "Claude Code found at $(command -v claude)"
# ── 2. Create directories ──────────────────────────────────────────────────────
mkdir -p "$SKILL_DIR/scripts"
mkdir -p "$SKILL_DIR/references"
mkdir -p "$HOME/.claude/sessions"
info "Directories ready"
# ── 3. Download skill files ────────────────────────────────────────────────────
echo ""
echo "📥 Downloading skill files..."
for f in SKILL.md; do
curl -fsSL "$REPO/skill/$f" -o "$SKILL_DIR/$f"
info "$f"
done
for f in session-format.md log-discovery.md; do
curl -fsSL "$REPO/skill/references/$f" -o "$SKILL_DIR/references/$f"
info "references/$f"
done
for f in save_session.py list_sessions.py restore_session.py heartbeat.py diff_session.py dashboard.py; do
curl -fsSL "$REPO/skill/scripts/$f" -o "$SKILL_DIR/scripts/$f"
info "scripts/$f"
done
# ── 4. Syntax check ────────────────────────────────────────────────────────────
for f in save_session.py list_sessions.py restore_session.py heartbeat.py diff_session.py dashboard.py; do
python3 -m py_compile "$SKILL_DIR/scripts/$f" || error "Syntax error in $f"
done
info "All scripts verified (syntax OK)"
# ── 5. Patch settings.json ─────────────────────────────────────────────────────
echo ""
echo "⚙️ Configuring heartbeat hook in $SETTINGS ..."
[[ ! -f "$SETTINGS" ]] && echo '{}' > "$SETTINGS" && warn "Created new $SETTINGS"
python3 - "$SETTINGS" "$SKILL_DIR" <<'PYEOF'
import json, sys
from pathlib import Path
settings_path = Path(sys.argv[1])
skill_dir = sys.argv[2]
settings = json.loads(settings_path.read_text())
hooks = settings.setdefault("hooks", {})
new_hook = {
"type": "command",
"command": f"python3 {skill_dir}/scripts/heartbeat.py \"$PWD\"",
"async": True,
}
post_entries = hooks.setdefault("PostToolUse", [])
already = any(
e.get("matcher") == "Write|Edit|Bash" and any(
h.get("command", "") == new_hook["command"]
for h in e.get("hooks", [])
)
for e in post_entries
)
if not already:
post_entries.append({"matcher": "Write|Edit|Bash", "hooks": [new_hook]})
# SessionStart hook — auto-init session on every new window
init_hook = {
"type": "command",
"command": f"python3 {skill_dir}/scripts/save_session.py --working-dir \"$PWD\" --tool claude-code --action init",
"async": True,
}
start_entries = hooks.setdefault("SessionStart", [])
already_start = any(
any(h.get("command", "") == init_hook["command"] for h in e.get("hooks", []))
for e in start_entries
)
if not already_start:
start_entries.append({"hooks": [init_hook]})
settings_path.write_text(json.dumps(settings, indent=2, ensure_ascii=False) + "\n")
print("OK")
PYEOF
info "Heartbeat hook registered"
info "SessionStart auto-init hook registered"
# ── 6. Done ────────────────────────────────────────────────────────────────────
echo ""
echo "──────────────────────────────────────"
echo -e " ${GREEN}Installation complete!${NC}"
echo "──────────────────────────────────────"
echo ""
echo " Next step → type /hooks in Claude Code to reload config."
echo ""
echo " How it works:"
echo " • Sessions are auto-tracked after every file write or bash command."
echo " • On new session start, Claude checks for recoverable previous sessions."
echo " • Say '保存进度' or 'save session' to create a rich checkpoint."
echo ""
echo " Repo: https://github.com/yizhiyanhua-ai/fireworks-sessions-saver"
echo ""
MIT License
Copyright (c) 2026 yizhiyanhua-ai
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
<div align="center">
<img src="https://raw.githubusercontent.com/yizhiyanhua-ai/fireworks-sessions-saver/main/docs/logo.svg" alt="fireworks-sessions-saver" width="80" />
fireworks-sessions-saver
Never lose your coding session context again.
Automatically persists and restores session state for Claude Code. Codex support coming soon.
     
中文文档 · Report Bug · Request Feature
</div>
---
The Problem
Network timeouts, crashes, and accidental window closures kill your session context. Re-establishing it in a new window wastes time and tokens.
Session 1: Deep in a complex refactor — network drops ✗ context gone
New session: "What were we working on?" — 10 minutes re-explaining ✗ expensiveThe Solution
fireworks-sessions-saver automatically tracks what you're working on and makes it instantly available when you reconnect — in any new Claude Code or Codex window.
Session 1: Working on auth refactor → auto-tracked every tool call
Network drops
New session: "Found 1 previous session — restore?" → one keystroke ✓ back in 5 seconds---
Install
In Claude Code or Codex, just say:
"Install fireworks-sessions-saver from https://github.com/yizhiyanhua-ai/fireworks-sessions-saver"
---
How It Works
Architecture
<img src="https://raw.githubusercontent.com/yizhiyanhua-ai/fireworks-sessions-saver/main/docs/architecture.svg" alt="Architecture" width="100%"/>
Left — Tracking flow: Every Write, Edit, or Bash call fires heartbeat.py asynchronously (<5ms), updating last_active and merging git-modified file paths into the active session file. When you say "save session", a rich checkpoint is written — capturing the current task, key decisions, open questions, file references, git branch, and log paths. On the next session init, the active file is archived.
Right — Recovery flow: When a new session opens, list_sessions.py automatically scans for sessions in the same working directory active within the last 7 days. After you select one, restore_session.py prints a structured context summary. Once the first new checkpoint is saved, the archive file is deleted — no stale files accumulate.
Components
<img src="https://raw.githubusercontent.com/yizhiyanhua-ai/fireworks-sessions-saver/main/docs/components.svg" alt="Components" width="100%"/>
Four layers, top to bottom:
- CLI Tools — Claude Code and Codex are the primary supported tools; the architecture is open to any coding CLI.
- Hook Layer — A single
PostToolUsehook insettings.jsonfiresheartbeat.pyasynchronously after every file write or shell command. Zero impact on your workflow. - Scripts — Four focused Python scripts:
heartbeat.py(auto, lightweight),save_session.py(init / checkpoint / cleanup),list_sessions.py(scan & rank),restore_session.py(format & print). - Storage — Two JSON file types in
~/.claude/sessions/:active_{hash}.jsonfor the running session (rolling 10 checkpoints), andarchive_{hash}_{ts}.jsonfor sessions awaiting restore or expiry.
---
Usage
Automatic (via hook)
The heartbeat runs silently after every Write, Edit, or Bash call. No action needed.
Save a rich checkpoint
Say any of:
save session/save progress保存进度/保存状态
Claude will capture: current task, files in context, key decisions, open questions, recent commands, and log file paths.
Restore a previous session
In a new window, Claude automatically checks for recoverable sessions on startup. Or say:
restore session/continue from last session恢复会话/继续之前的工作
View all sessions (dashboard)
Say any of:
dashboard/show all sessions/which projects are active查看所有 session/多项目看板
Claude will run the dashboard and show all active sessions across every working directory.
# Manual CLI usage
python3 ~/.claude/skills/fireworks-sessions-saver/scripts/dashboard.py
python3 ~/.claude/skills/fireworks-sessions-saver/scripts/dashboard.py --all # include expiredDiff two checkpoints
Say any of:
diff checkpoint/what changed since last checkpoint对比进度/两次 checkpoint 有什么变化
Claude will compare the last two checkpoints and show what changed in task, files, decisions, and open questions.
# Manual CLI usage
python3 ~/.claude/skills/fireworks-sessions-saver/scripts/diff_session.py \
--session-file ~/.claude/sessions/active_<hash>.json
python3 ~/.claude/skills/fireworks-sessions-saver/scripts/diff_session.py \
--session-file ~/.claude/sessions/active_<hash>.json --list # list all checkpoints
python3 ~/.claude/skills/fireworks-sessions-saver/scripts/diff_session.py \
--session-file ~/.claude/sessions/active_<hash>.json --from 2 --to 4Manual CLI usage
# List recoverable sessions for current directory
python3 ~/.claude/skills/fireworks-sessions-saver/scripts/list_sessions.py
# Restore a specific session
python3 ~/.claude/skills/fireworks-sessions-saver/scripts/restore_session.py \
--session-file ~/.claude/sessions/archive_abc12345_20260408_143022.json---
Storage
~/.claude/sessions/
active_{workdir_hash}.json ← current active session
archive_{workdir_hash}_{ts}.json ← archived (awaiting restore or expiry)- Sessions expire after 7 days of inactivity
- Each file holds at most 10 checkpoints (rolling window)
- Only file paths are stored — never file contents
- Archived files are deleted after successful transfer to a new session
---
Requirements
- Python 3.9+
- Claude Code CLI
- macOS / Linux
Codex CLI: Hook-based auto-tracking is not yet supported (Codex does not have a hook system). Manual checkpoint and restore via the scripts still work. Full Codex support is on the roadmap.
---
Roadmap
- [x] Claude Code — full auto-tracking via PostToolUse hook
- [ ] Codex CLI — auto-tracking support (planned)
- [x] Session diff view — show what changed between checkpoints
- [x] Multi-project dashboard — view all active sessions across directories
---
Project Structure
fireworks-sessions-saver/
├── skill/
│ ├── SKILL.md ← Claude Code skill definition
│ ├── references/
│ │ ├── session-format.md ← JSON schema docs
│ │ └── log-discovery.md ← Claude Code / Codex log locations
│ └── scripts/
│ ├── save_session.py ← init / checkpoint / cleanup
│ ├── list_sessions.py ← find recoverable sessions
│ ├── restore_session.py ← print structured context summary
│ ├── heartbeat.py ← lightweight async hook
│ ├── diff_session.py ← diff two checkpoints
│ └── dashboard.py ← multi-project session dashboard
├── install.sh
├── LICENSE
├── README.md
└── README.zh-CN.md---
Contributing
PRs welcome. Please open an issue first for significant changes.
License
MIT
<div align="center">
<img src="https://raw.githubusercontent.com/yizhiyanhua-ai/fireworks-sessions-saver/main/docs/logo.svg" alt="fireworks-sessions-saver" width="80" />
fireworks-sessions-saver
再也不会丢失编程会话上下文。
自动持久化并恢复 Claude Code 的会话状态。Codex 支持即将推出。
     
</div>
---
问题
网络超时、程序崩溃、不小心关掉窗口——会话上下文瞬间消失。在新窗口里重新建立上下文既浪费时间又浪费 token。
第 1 次: 正在做复杂的重构——网络断了 ✗ 上下文全没了
新 session:「我们刚才在做什么?」——花 10 分钟重新解释 ✗ 代价高昂解决方案
fireworks-sessions-saver 自动追踪你正在做的事,当你重新连接时——无论是哪个新窗口——立刻恢复现场。
第 1 次: 正在做 auth 重构 → 每次工具调用自动追踪
网络断了
新 session:「发现 1 个历史 session——是否恢复?」→ 一键确认 ✓ 5 秒回到现场---
安装
在 Claude Code 或 Codex 对话框里直接说:
"从 https://github.com/yizhiyanhua-ai/fireworks-sessions-saver 安装 fireworks-sessions-saver"
---
工作原理
架构图
<img src="https://raw.githubusercontent.com/yizhiyanhua-ai/fireworks-sessions-saver/main/docs/architecture.svg" alt="架构图" width="100%"/>
左侧——追踪链路: 每次 Write、Edit、Bash 调用都会异步触发 heartbeat.py(<5ms),更新 last_active 并将 git 修改的文件路径合并进 session 文件。说"保存进度"时,写入完整 checkpoint——记录当前任务、关键决策、未解决问题、文件引用、git 分支和日志路径。下次 session 初始化时,当前文件自动归档。
右侧——恢复链路: 新 session 启动时,list_sessions.py 自动扫描同一工作目录下 7 天内有活动的历史 session。用户选择后,restore_session.py 输出结构化上下文摘要。新 session 保存第一个 checkpoint 后,归档文件自动删除,不会积累冗余文件。
组件图
<img src="https://raw.githubusercontent.com/yizhiyanhua-ai/fireworks-sessions-saver/main/docs/components.svg" alt="组件图" width="100%"/>
从上到下四层:
- CLI 工具层 — 主要支持 Claude Code 和 Codex,架构对其他 coding CLI 工具开放扩展。
- Hook 层 —
settings.json中一条PostToolUsehook,在每次文件写入或命令执行后异步触发heartbeat.py,对使用流程零影响。 - 脚本层 — 四个职责单一的 Python 脚本:
heartbeat.py(自动、轻量)、save_session.py(初始化 / checkpoint / 清理)、list_sessions.py(扫描 & 排序)、restore_session.py(格式化 & 输出)。 - 存储层 —
~/.claude/sessions/下两类 JSON 文件:active_{hash}.json存储当前 session(滚动 10 条 checkpoint),archive_{hash}_{ts}.json存储等待恢复或过期的历史 session。
---
使用方式
自动(通过 hook)
心跳在每次 Write、Edit、Bash 调用后静默运行,无需任何操作。
保存完整 checkpoint
说任意一种:
保存进度/保存状态/save session
Claude 会记录:当前任务、上下文文件、关键决策、未解决问题、最近命令、日志文件路径。
恢复历史 session
新窗口启动时 Claude 会自动检查可恢复的 session。也可以主动说:
恢复会话/继续之前的工作/restore session
查看多项目看板
说任意一种:
查看所有 session/多项目看板/哪些项目在跑dashboard/show all sessions
Claude 会运行看板,展示所有工作目录下的活跃 session。
# 命令行手动使用
python3 ~/.claude/skills/fireworks-sessions-saver/scripts/dashboard.py
python3 ~/.claude/skills/fireworks-sessions-saver/scripts/dashboard.py --all # 含过期对比两次 checkpoint
说任意一种:
对比进度/两次 checkpoint 有什么变化/checkpoint 差异diff checkpoint/what changed since last checkpoint
Claude 会对比最近两次 checkpoint,展示任务、文件、决策、未解决问题的变化。
# 命令行手动使用
python3 ~/.claude/skills/fireworks-sessions-saver/scripts/diff_session.py \
--session-file ~/.claude/sessions/active_<hash>.json
python3 ~/.claude/skills/fireworks-sessions-saver/scripts/diff_session.py \
--session-file ~/.claude/sessions/active_<hash>.json --list # 列出所有 checkpoint
python3 ~/.claude/skills/fireworks-sessions-saver/scripts/diff_session.py \
--session-file ~/.claude/sessions/active_<hash>.json --from 2 --to 4命令行手动使用
# 列出当前目录的可恢复 session
python3 ~/.claude/skills/fireworks-sessions-saver/scripts/list_sessions.py
# 恢复指定 session
python3 ~/.claude/skills/fireworks-sessions-saver/scripts/restore_session.py \
--session-file ~/.claude/sessions/archive_abc12345_20260408_143022.json---
存储说明
~/.claude/sessions/
active_{workdir_hash}.json ← 当前活跃 session
archive_{workdir_hash}_{ts}.json ← 已归档(等待恢复或过期)- session 7 天无活动自动过期
- 每个文件最多保留 10 条 checkpoint(滚动窗口)
- 只存储文件路径,从不存储文件内容
- 成功转移到新 session 后,归档文件自动删除
---
环境要求
- Python 3.9+
- Claude Code CLI
- macOS / Linux
Codex CLI:基于 hook 的自动追踪暂不支持(Codex 没有 hook 系统)。手动 checkpoint 和恢复脚本仍可正常使用。完整 Codex 支持已列入计划。
---
路线图
- [x] Claude Code — 通过 PostToolUse hook 全自动追踪
- [ ] Codex CLI — 自动追踪支持(计划中)
- [x] Session diff 视图 — 展示两次 checkpoint 之间的变化
- [x] 多项目看板 — 跨目录查看所有活跃 session
- Python 3.9+
- Claude Code CLI(或 Codex CLI)
- macOS / Linux
---
项目结构
fireworks-sessions-saver/
├── skill/
│ ├── SKILL.md ← Claude Code skill 定义
│ ├── references/
│ │ ├── session-format.md ← JSON schema 文档
│ │ └── log-discovery.md ← Claude Code / Codex 日志路径说明
│ └── scripts/
│ ├── save_session.py ← 初始化 / checkpoint / 清理
│ ├── list_sessions.py ← 查找可恢复 session
│ ├── restore_session.py ← 输出结构化恢复摘要
│ ├── heartbeat.py ← 轻量级异步 hook
│ ├── diff_session.py ← 对比两次 checkpoint 差异
│ └── dashboard.py ← 多项目 session 看板
├── install.sh
├── LICENSE
├── README.md
└── README.zh-CN.md---
贡献
欢迎 PR。重大改动请先开 issue 讨论。
许可证
MIT
Security Policy
Supported Versions
| Version | Supported |
|---|---|
| 1.x | ✓ |
Reporting a Vulnerability
Please do not open a public GitHub issue for security vulnerabilities.
Instead, report them via GitHub's private vulnerability reporting: Security → Report a vulnerability on the repository page.
We aim to respond within 48 hours and will coordinate a fix and disclosure timeline with you.
Scope
This project runs locally on your machine and stores session data in ~/.claude/sessions/. It does not transmit any data externally. The main security considerations are:
- Session files may contain file paths and task summaries from your projects — treat
~/.claude/sessions/as sensitive - The heartbeat hook runs after every
Write/Edit/Bashtool call — review the hook command in yoursettings.jsonif you have concerns
Log File Locations for Claude Code and Codex
When restoring a session, these log files can provide supplementary context beyond what was manually checkpointed.
Important: Only store file paths in session files — never copy log content. Logs can be large and change frequently.
---
Claude Code
Project Conversation Logs
Claude Code stores per-project conversation history in:
~/.claude/projects/{project_dir_encoded}/The directory name is derived from the absolute working directory path (slashes replaced with hyphens or similar encoding). To find the right project directory:
# List all project dirs sorted by modification time
ls -lt ~/.claude/projects/ | head -20
# Or search for a dir that matches your project name
ls ~/.claude/projects/ | grep <project_name_fragment>Inside each project directory, look for:
*.jsonl— conversation logs (one entry per line, JSON format)todos.json— saved todo lists
Each JSONL line contains a message object with role, content, and tool use records.
Global Files
| Path | Contents |
|---|---|
~/.claude/settings.json | Global settings, hooks config |
~/.claude/settings.local.json | Local overrides |
~/.claude/todos/ | Global todo lists |
~/.claude/skills/ | Installed skills |
~/.claude/CLAUDE.md | Global instructions |
Finding the Most Recent Log
import os
from pathlib import Path
projects_dir = Path.home() / ".claude" / "projects"
if projects_dir.exists():
# Find most recently modified project dir
project_dirs = sorted(
projects_dir.iterdir(),
key=lambda p: p.stat().st_mtime,
reverse=True
)
for pd in project_dirs[:3]:
logs = sorted(pd.glob("*.jsonl"), key=lambda f: f.stat().st_mtime, reverse=True)
if logs:
print(f"Recent log: {logs[0]}")
break---
Codex (OpenAI CLI)
Codex CLI stores data in:
~/.codex/Common subdirectories (may vary by version):
| Path | Contents |
|---|---|
~/.codex/history | Command/conversation history |
~/.codex/logs/ | Session logs |
~/.codex/config.json | Configuration |
To discover what's available:
ls -la ~/.codex/
find ~/.codex/ -name "*.json" -o -name "*.jsonl" | sortFinding Recent Codex Logs
from pathlib import Path
codex_dir = Path.home() / ".codex"
if codex_dir.exists():
log_files = sorted(
list(codex_dir.rglob("*.json")) + list(codex_dir.rglob("*.jsonl")),
key=lambda f: f.stat().st_mtime,
reverse=True
)
for f in log_files[:5]:
print(f)---
Using Log References in Checkpoints
When saving a checkpoint, the save_session.py script automatically discovers and records log file paths in the log_refs field:
"log_refs": {
"claude_code": "/Users/user/.claude/projects/my-project-abc123/2026-04-08.jsonl",
"codex": null
}When restoring, these paths let you (or the AI) quickly locate the full conversation history for deeper context recovery.
Session File JSON Schema
File Naming
| Pattern | Purpose |
|---|---|
active_{workdir_hash}.json | Currently active session for a working directory |
archive_{workdir_hash}_{timestamp}.json | Archived session (from a previous run) |
workdir_hash = first 8 chars of MD5(absolute_working_dir_path) timestamp = YYYYMMDD_HHMMSS (UTC)
---
Top-Level Fields
{
"session_id": "a1b2c3d4",
"tool": "claude-code",
"working_dir": "/absolute/path/to/project",
"start_time": "2026-04-08T10:00:00",
"last_active": "2026-04-08T11:30:00",
"status": "active",
"checkpoints": []
}| Field | Type | Description |
|---|---|---|
session_id | string | 8-char random hex ID |
tool | string | "claude-code" or "codex" |
working_dir | string | Absolute path to project directory |
start_time | ISO8601 | When this session was created |
last_active | ISO8601 | Last heartbeat or checkpoint time |
status | string | "active", "transferred" |
checkpoints | array | Up to 10 checkpoint objects (rolling window) |
---
Checkpoint Object
{
"timestamp": "2026-04-08T11:30:00",
"summary": "Implementing JWT auth middleware",
"current_task": "Fix token expiry logic in src/auth.ts",
"git_branch": "feature/auth",
"git_recent_commits": [
"abc1234 Add JWT validation middleware",
"def5678 Setup auth route handlers"
],
"files": [
{
"path": "/abs/path/src/auth.ts",
"last_modified": "2026-04-08T11:25:00",
"role": "editing",
"note": "Adding token refresh logic"
},
{
"path": "/abs/path/tests/auth.test.ts",
"last_modified": "2026-04-08T11:10:00",
"role": "reference",
"note": ""
}
],
"key_decisions": [
"Using RS256 over HS256 for multi-service token verification"
],
"open_questions": [
"API response format for expired token — 401 or 403?"
],
"recent_commands": [
"git status",
"npm test -- --grep auth"
],
"log_refs": {
"claude_code": "~/.claude/projects/abc12345/conversations/2026-04-08.jsonl",
"codex": null
}
}Checkpoint Fields
| Field | Type | Description |
|---|---|---|
timestamp | ISO8601 | When checkpoint was saved |
summary | string | One-line description of current state |
current_task | string | Specific task in progress |
git_branch | string | Current git branch (null if not a git repo) |
git_recent_commits | string[] | Last 3–5 git log --oneline entries |
files | FileRef[] | Files in context (paths only, never content) |
key_decisions | string[] | Important decisions made this session |
open_questions | string[] | Unresolved questions or blockers |
recent_commands | string[] | Shell commands recently run |
log_refs | object | Paths to tool log files for deeper context |
FileRef Fields
| Field | Type | Description |
|---|---|---|
path | string | Absolute file path |
last_modified | ISO8601 | File mtime at checkpoint time |
role | string | "editing", "reference", "created", "deleted" |
note | string | Brief note on what was being done with this file |
---
Status Values
| Status | Meaning |
|---|---|
active | Session is currently running or was recently active |
transferred | Context was restored into a new session; safe to delete |
#!/usr/bin/env python3
"""Multi-project session dashboard — show all active sessions across all directories.
Usage: python dashboard.py [--all] [--json]
--all Include sessions that are expired (> 7 days) but not yet deleted
--json Output raw JSON instead of formatted table
"""
import argparse
import json
import sys
from datetime import datetime, timedelta
from pathlib import Path
SESSIONS_DIR = Path.home() / ".claude" / "sessions"
EXPIRY_DAYS = 7
def time_ago(dt: datetime) -> str:
delta = datetime.now() - dt
if delta.days > 0:
return f"{delta.days}d ago"
h = delta.seconds // 3600
if h > 0:
return f"{h}h ago"
m = delta.seconds // 60
return f"{m}m ago" if m > 0 else "just now"
def load_all_sessions(include_expired: bool = False) -> list:
if not SESSIONS_DIR.exists():
return []
sessions = []
cutoff = datetime.now() - timedelta(days=EXPIRY_DAYS)
for f in sorted(SESSIONS_DIR.glob("*.json")):
try:
data = json.loads(f.read_text())
except Exception:
continue
if data.get("status") == "transferred":
continue
last_active_str = data.get("last_active", "")
try:
last_active = datetime.fromisoformat(last_active_str)
except Exception:
continue
expired = last_active < cutoff
if expired and not include_expired:
continue
checkpoints = data.get("checkpoints", [])
latest = checkpoints[-1] if checkpoints else {}
sessions.append({
"file": str(f),
"session_id": data.get("session_id", "?"),
"tool": data.get("tool", "unknown"),
"working_dir": data.get("working_dir", "?"),
"start_time": data.get("start_time", "?"),
"last_active": last_active,
"last_active_str": last_active_str,
"status": "expired" if expired else data.get("status", "active"),
"checkpoint_count": len(checkpoints),
"summary": latest.get("summary", ""),
"current_task": latest.get("current_task", ""),
"git_branch": latest.get("git_branch", ""),
"file_count": len(latest.get("files", [])),
})
sessions.sort(key=lambda s: s["last_active"], reverse=True)
return sessions
def print_dashboard(sessions: list) -> None:
if not sessions:
print("No active sessions found.")
print(f"Sessions are stored in: {SESSIONS_DIR}")
return
active = [s for s in sessions if s["status"] == "active"]
expired = [s for s in sessions if s["status"] == "expired"]
print("=" * 70)
print(f" SESSION DASHBOARD — {len(sessions)} session(s) found")
print("=" * 70)
if active:
print(f"\n ACTIVE ({len(active)})\n")
for i, s in enumerate(active, 1):
ago = time_ago(s["last_active"])
branch = f" [{s['git_branch']}]" if s["git_branch"] else ""
print(f" [{i}] {s['last_active'].strftime('%Y-%m-%d %H:%M')} ({ago}) — {s['tool']}{branch}")
print(f" Dir: {s['working_dir']}")
if s["summary"]:
print(f" Last: {s['summary'][:60]}")
if s["current_task"]:
print(f" Task: {s['current_task'][:60]}")
print(f" CPs: {s['checkpoint_count']} · Files: {s['file_count']}")
print(f" File: {s['file']}")
print()
if expired:
print(f" EXPIRED ({len(expired)}) — older than {EXPIRY_DAYS} days\n")
for s in expired:
ago = time_ago(s["last_active"])
print(f" · {s['last_active'].strftime('%Y-%m-%d')} ({ago}) {s['working_dir']}")
print()
print("=" * 70)
print(f" Storage: {SESSIONS_DIR}")
print("=" * 70)
def main() -> int:
parser = argparse.ArgumentParser(description="Multi-project session dashboard")
parser.add_argument("--all", action="store_true", help="Include expired sessions")
parser.add_argument("--json", action="store_true", help="Output raw JSON")
args = parser.parse_args()
sessions = load_all_sessions(include_expired=args.all)
if args.json:
output = [
{k: v for k, v in s.items() if k != "last_active"}
for s in sessions
]
print(json.dumps(output, indent=2, ensure_ascii=False))
return 0
print_dashboard(sessions)
return 0
if __name__ == "__main__":
sys.exit(main())
#!/usr/bin/env python3
"""Show a diff between two checkpoints in a session file.
Usage:
python diff_session.py --session-file <path> [--from N] [--to M]
N and M are 1-based checkpoint indices (default: last two checkpoints).
Use --list to show all available checkpoints.
"""
import argparse
import json
import sys
from datetime import datetime
from pathlib import Path
def time_ago(iso: str) -> str:
try:
delta = datetime.now() - datetime.fromisoformat(iso)
if delta.days > 0:
return f"{delta.days}d ago"
h = delta.seconds // 3600
if h > 0:
return f"{h}h ago"
m = delta.seconds // 60
return f"{m}m ago" if m > 0 else "just now"
except Exception:
return iso
def diff_list(old: list, new: list) -> list[str]:
old_set, new_set = set(old), set(new)
lines = []
for item in old_set - new_set:
lines.append(f" - {item}")
for item in new_set - old_set:
lines.append(f" + {item}")
return lines
def diff_files(old: list, new: list) -> list[str]:
old_map = {f["path"]: f for f in old}
new_map = {f["path"]: f for f in new}
lines = []
for path in set(old_map) - set(new_map):
lines.append(f" - {path} [{old_map[path].get('role', '')}]")
for path in set(new_map) - set(old_map):
lines.append(f" + {path} [{new_map[path].get('role', '')}]")
for path in set(old_map) & set(new_map):
o, n = old_map[path], new_map[path]
if o.get("role") != n.get("role"):
lines.append(f" ~ {path} {o.get('role','')} → {n.get('role','')}")
elif o.get("note") != n.get("note") and n.get("note"):
lines.append(f" ~ {path} note: {n.get('note','')}")
return lines
def main() -> int:
parser = argparse.ArgumentParser(description="Diff two session checkpoints")
parser.add_argument("--session-file", required=True)
parser.add_argument("--from", dest="from_idx", type=int, default=None,
help="1-based index of the older checkpoint")
parser.add_argument("--to", dest="to_idx", type=int, default=None,
help="1-based index of the newer checkpoint")
parser.add_argument("--list", action="store_true", help="List all checkpoints")
args = parser.parse_args()
path = Path(args.session_file)
if not path.exists():
print(f"File not found: {path}", file=sys.stderr)
return 1
try:
session = json.loads(path.read_text())
except Exception as e:
print(f"Failed to parse session file: {e}", file=sys.stderr)
return 1
checkpoints = session.get("checkpoints", [])
if not checkpoints:
print("No checkpoints found in this session.")
return 0
if args.list:
print(f"Checkpoints in {path.name}:")
for i, cp in enumerate(checkpoints, 1):
print(f" [{i}] {cp.get('timestamp', '?')} — {cp.get('summary', '(no summary)')[:60]}")
return 0
n = len(checkpoints)
from_idx = args.from_idx if args.from_idx is not None else max(1, n - 1)
to_idx = args.to_idx if args.to_idx is not None else n
if not (1 <= from_idx <= n) or not (1 <= to_idx <= n):
print(f"Index out of range. Session has {n} checkpoint(s).", file=sys.stderr)
return 1
if from_idx == to_idx:
print("--from and --to must be different checkpoints.", file=sys.stderr)
return 1
old_cp = checkpoints[from_idx - 1]
new_cp = checkpoints[to_idx - 1]
print("=" * 60)
print(f"CHECKPOINT DIFF [{from_idx}] → [{to_idx}]")
print("=" * 60)
print(f"From: {old_cp.get('timestamp', '?')} ({time_ago(old_cp.get('timestamp',''))})")
print(f"To: {new_cp.get('timestamp', '?')} ({time_ago(new_cp.get('timestamp',''))})")
print()
changed = False
# Task
old_task = old_cp.get("current_task") or ""
new_task = new_cp.get("current_task") or ""
if old_task != new_task:
changed = True
print("── Task ──────────────────────────────────────────────")
if old_task:
print(f" - {old_task}")
if new_task:
print(f" + {new_task}")
print()
# Summary
old_sum = old_cp.get("summary") or ""
new_sum = new_cp.get("summary") or ""
if old_sum != new_sum:
changed = True
print("── Summary ───────────────────────────────────────────")
if old_sum:
print(f" - {old_sum}")
if new_sum:
print(f" + {new_sum}")
print()
# Files
file_lines = diff_files(old_cp.get("files", []), new_cp.get("files", []))
if file_lines:
changed = True
print("── Files ─────────────────────────────────────────────")
for line in file_lines:
print(line)
print()
# Decisions
dec_lines = diff_list(
old_cp.get("key_decisions", []),
new_cp.get("key_decisions", [])
)
if dec_lines:
changed = True
print("── Key Decisions ─────────────────────────────────────")
for line in dec_lines:
print(line)
print()
# Open questions
q_lines = diff_list(
old_cp.get("open_questions", []),
new_cp.get("open_questions", [])
)
if q_lines:
changed = True
print("── Open Questions ────────────────────────────────────")
for line in q_lines:
print(line)
print()
# Git branch
old_branch = old_cp.get("git_branch")
new_branch = new_cp.get("git_branch")
if old_branch != new_branch:
changed = True
print("── Git Branch ────────────────────────────────────────")
print(f" - {old_branch or '(none)'}")
print(f" + {new_branch or '(none)'}")
print()
if not changed:
print("No differences found between the two checkpoints.")
print("=" * 60)
return 0
if __name__ == "__main__":
sys.exit(main())
#!/usr/bin/env python3
"""Lightweight heartbeat — updates last_active and records recently touched files.
Called automatically via PostToolUse hook. Designed to be fast and silent.
Does NOT create a full checkpoint; that's the AI's responsibility.
Usage: python heartbeat.py [working_dir]
"""
import hashlib
import json
import os
import subprocess
import sys
from datetime import datetime
from pathlib import Path
SESSIONS_DIR = Path.home() / ".claude" / "sessions"
def now_iso() -> str:
return datetime.now().strftime("%Y-%m-%dT%H:%M:%S")
def workdir_hash(working_dir: str) -> str:
return hashlib.md5(working_dir.encode()).hexdigest()[:8]
def get_modified_files(working_dir: str) -> "list[str]":
"""Return absolute paths of files modified in git working tree."""
try:
out = subprocess.check_output(
["git", "diff", "--name-only", "HEAD"],
cwd=working_dir, stderr=subprocess.DEVNULL
).decode().strip()
files = [os.path.join(working_dir, f) for f in out.split("\n") if f]
# Also include untracked files
untracked = subprocess.check_output(
["git", "ls-files", "--others", "--exclude-standard"],
cwd=working_dir, stderr=subprocess.DEVNULL
).decode().strip()
files += [os.path.join(working_dir, f) for f in untracked.split("\n") if f]
return files[:10] # cap to avoid bloat
except Exception:
return []
def main():
working_dir = os.path.abspath(sys.argv[1] if len(sys.argv) > 1 else os.getcwd())
ap = SESSIONS_DIR / f"active_{workdir_hash(working_dir)}.json"
if not ap.exists():
return # no active session, nothing to do
try:
session = json.loads(ap.read_text())
except Exception:
return
session["last_active"] = now_iso()
# Merge recently modified files into the latest checkpoint's file list
modified = get_modified_files(working_dir)
if modified and session.get("checkpoints"):
latest = session["checkpoints"][-1]
existing_paths = {f["path"] for f in latest.get("files", [])}
for fpath in modified:
if fpath not in existing_paths:
p = Path(fpath)
mtime = None
try:
mtime = datetime.fromtimestamp(p.stat().st_mtime).strftime("%Y-%m-%dT%H:%M:%S")
except Exception:
pass
latest.setdefault("files", []).append({
"path": fpath,
"last_modified": mtime,
"role": "editing",
"note": ""
})
# Keep file list bounded
latest["files"] = latest["files"][-20:]
try:
ap.write_text(json.dumps(session, indent=2, ensure_ascii=False))
except Exception:
pass # silent failure — heartbeat must never break the user's workflow
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""List recoverable sessions for a working directory.
Usage: python list_sessions.py [working_dir]
"""
import hashlib
import json
import os
import sys
from datetime import datetime, timedelta
from pathlib import Path
SESSIONS_DIR = Path.home() / ".claude" / "sessions"
EXPIRY_DAYS = 7
def workdir_hash(working_dir: str) -> str:
return hashlib.md5(working_dir.encode()).hexdigest()[:8]
def time_ago(dt: datetime) -> str:
delta = datetime.now() - dt
if delta.days > 0:
return f"{delta.days} day(s) ago"
hours = delta.seconds // 3600
if hours > 0:
return f"{hours} hour(s) ago"
minutes = delta.seconds // 60
return f"{minutes} minute(s) ago" if minutes > 0 else "just now"
def load_session_summary(path: Path) -> dict | None:
try:
data = json.loads(path.read_text())
if data.get("status") == "transferred":
return None
last_active = datetime.fromisoformat(data.get("last_active", data["start_time"]))
if last_active < datetime.now() - timedelta(days=EXPIRY_DAYS):
return None
checkpoints = data.get("checkpoints", [])
if not checkpoints:
return None
latest = checkpoints[-1]
return {
"file": str(path),
"tool": data.get("tool", "unknown"),
"last_active": last_active,
"summary": latest.get("summary", "No summary"),
"current_task": latest.get("current_task", ""),
"open_questions": latest.get("open_questions", []),
"files": [f["path"] for f in latest.get("files", [])],
"checkpoint_count": len(checkpoints),
}
except Exception:
return None
def main():
working_dir = os.path.abspath(sys.argv[1] if len(sys.argv) > 1 else os.getcwd())
wh = workdir_hash(working_dir)
if not SESSIONS_DIR.exists():
print("No previous sessions found.")
return
sessions = []
# Check archive files for this working dir
for f in SESSIONS_DIR.glob(f"archive_{wh}_*.json"):
s = load_session_summary(f)
if s:
sessions.append(s)
# Check active file — may be from a crashed previous session
active = SESSIONS_DIR / f"active_{wh}.json"
s = load_session_summary(active)
if s:
sessions.append(s)
sessions.sort(key=lambda x: x["last_active"], reverse=True)
if not sessions:
print("No previous sessions found for this project.")
return
print(f"Found {len(sessions)} previous session(s) for: {working_dir}\n")
for i, s in enumerate(sessions, 1):
ago = time_ago(s["last_active"])
print(f"[{i}] {s['last_active'].strftime('%Y-%m-%d %H:%M')} ({ago}) — {s['tool']}")
print(f" Summary: {s['summary']}")
if s["current_task"]:
print(f" Task: {s['current_task']}")
if s["files"]:
print(f" Files: {', '.join(s['files'][:3])}")
if s["open_questions"]:
print(f" Open: {s['open_questions'][0]}")
print(f" File: {s['file']}")
print()
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""Print a structured context summary from a session archive file.
Usage: python restore_session.py --session-file <path>
"""
import argparse
import json
from datetime import datetime
from pathlib import Path
def time_ago(iso: str) -> str:
try:
dt = datetime.fromisoformat(iso)
delta = datetime.now() - dt
if delta.days > 0:
return f"{delta.days} day(s) ago"
hours = delta.seconds // 3600
if hours > 0:
return f"{hours} hour(s) ago"
minutes = delta.seconds // 60
return f"{minutes} minute(s) ago" if minutes > 0 else "just now"
except Exception:
return iso
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--session-file", required=True)
args = parser.parse_args()
path = Path(args.session_file)
if not path.exists():
print(f"Session file not found: {path}")
return 1
data = json.loads(path.read_text())
checkpoints = data.get("checkpoints", [])
print("=" * 60)
print("SESSION CONTEXT RESTORE")
print("=" * 60)
print(f"Tool: {data.get('tool', 'unknown')}")
print(f"Working dir: {data.get('working_dir', '')}")
print(f"Session ID: {data.get('session_id', '')}")
print(f"Started: {data.get('start_time', '')} ({time_ago(data.get('start_time', ''))})")
print(f"Last active: {data.get('last_active', '')} ({time_ago(data.get('last_active', ''))})")
print(f"Checkpoints: {len(checkpoints)}")
print()
if not checkpoints:
print("No checkpoints recorded.")
return 0
# Most recent checkpoint is the primary restore point
latest = checkpoints[-1]
print("─" * 60)
print("LATEST STATE")
print("─" * 60)
if latest.get("summary"):
print(f"Summary: {latest['summary']}")
if latest.get("current_task"):
print(f"Current task: {latest['current_task']}")
if latest.get("git_branch"):
print(f"Git branch: {latest['git_branch']}")
commits = latest.get("git_recent_commits", [])
if commits:
print("Recent commits:")
for c in commits:
print(f" {c}")
files = latest.get("files", [])
if files:
print("Files in context:")
for f in files:
role = f.get("role", "")
note = f.get("note", "")
mtime = f.get("last_modified", "")
exists = Path(f["path"]).exists()
status = "" if exists else " [MISSING]"
line = f" [{role}] {f['path']}{status}"
if mtime:
line += f" (modified {mtime})"
if note:
line += f"\n → {note}"
print(line)
decisions = latest.get("key_decisions", [])
if decisions:
print("Key decisions:")
for d in decisions:
print(f" • {d}")
questions = latest.get("open_questions", [])
if questions:
print("Open questions / blockers:")
for q in questions:
print(f" ? {q}")
commands = latest.get("recent_commands", [])
if commands:
print("Recent commands:")
for c in commands:
print(f" $ {c}")
log_refs = latest.get("log_refs", {})
cc_log = log_refs.get("claude_code")
codex_log = log_refs.get("codex")
if cc_log or codex_log:
print("Log files (for deeper context):")
if cc_log:
exists = Path(cc_log).exists()
print(f" Claude Code: {cc_log}{'' if exists else ' [NOT FOUND]'}")
if codex_log:
exists = Path(codex_log).exists()
print(f" Codex: {codex_log}{'' if exists else ' [NOT FOUND]'}")
# Show older checkpoints as a timeline if there are multiple
if len(checkpoints) > 1:
print()
print("─" * 60)
print("CHECKPOINT HISTORY")
print("─" * 60)
for i, cp in enumerate(reversed(checkpoints[:-1]), 1):
ts = cp.get("timestamp", "")
summary = cp.get("summary", "—")
task = cp.get("current_task", "")
print(f" [{i} back] {ts} {summary}")
if task and task != summary:
print(f" task: {task}")
print()
print("=" * 60)
print("Restore complete. Use the above context to resume work.")
print(f"Session file: {path}")
print("=" * 60)
return 0
if __name__ == "__main__":
exit(main())
#!/usr/bin/env python3
"""Save or update a session checkpoint.
Actions:
init Create a new active session (archives existing one if it has checkpoints)
checkpoint Add a checkpoint to the active session
cleanup Delete an archive file after successful transfer
"""
import argparse
import hashlib
import json
import os
import subprocess
import uuid
from datetime import datetime
from pathlib import Path
SESSIONS_DIR = Path.home() / ".claude" / "sessions"
MAX_CHECKPOINTS = 10
def workdir_hash(working_dir: str) -> str:
return hashlib.md5(working_dir.encode()).hexdigest()[:8]
def now_iso() -> str:
return datetime.now().strftime("%Y-%m-%dT%H:%M:%S")
def now_ts() -> str:
return datetime.now().strftime("%Y%m%d_%H%M%S")
def active_path(working_dir: str) -> Path:
return SESSIONS_DIR / f"active_{workdir_hash(working_dir)}.json"
def get_git_info(working_dir: str) -> dict:
info = {"branch": None, "recent_commits": []}
try:
branch = subprocess.check_output(
["git", "branch", "--show-current"],
cwd=working_dir, stderr=subprocess.DEVNULL
).decode().strip()
info["branch"] = branch or None
commits = subprocess.check_output(
["git", "log", "--oneline", "-5"],
cwd=working_dir, stderr=subprocess.DEVNULL
).decode().strip()
info["recent_commits"] = [c for c in commits.split("\n") if c]
except Exception:
pass
return info
def get_log_refs(tool: str) -> dict:
refs = {"claude_code": None, "codex": None} # type: dict
if tool in ("claude-code", "claude"):
projects_dir = Path.home() / ".claude" / "projects"
if projects_dir.exists():
all_logs = sorted(
projects_dir.rglob("*.jsonl"),
key=lambda f: f.stat().st_mtime,
reverse=True
)
if all_logs:
refs["claude_code"] = str(all_logs[0])
if tool == "codex":
codex_dir = Path.home() / ".codex"
if codex_dir.exists():
all_logs = sorted(
list(codex_dir.rglob("*.json")) + list(codex_dir.rglob("*.jsonl")),
key=lambda f: f.stat().st_mtime,
reverse=True
)
if all_logs:
refs["codex"] = str(all_logs[0])
return refs
def parse_files(files_str: str) -> list:
"""Parse 'path:role,path:role' into file ref objects."""
result = []
if not files_str:
return result
for entry in files_str.split(","):
entry = entry.strip()
if not entry:
continue
if ":" in entry:
path, role = entry.rsplit(":", 1)
else:
path, role = entry, "reference"
p = Path(path.strip())
mtime = None
if p.exists():
mtime = datetime.fromtimestamp(p.stat().st_mtime).strftime("%Y-%m-%dT%H:%M:%S")
result.append({
"path": str(p),
"last_modified": mtime,
"role": role.strip(),
"note": ""
})
return result
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--working-dir", required=True)
parser.add_argument("--tool", default="claude-code")
parser.add_argument("--action", choices=["init", "checkpoint", "cleanup"], required=True)
parser.add_argument("--summary", default="")
parser.add_argument("--current-task", default="")
parser.add_argument("--files", default="",
help="Comma-separated 'path:role' pairs, e.g. src/auth.ts:editing,README.md:reference")
parser.add_argument("--decisions", default="", help="Semicolon-separated key decisions")
parser.add_argument("--open-questions", default="", help="Semicolon-separated open questions")
parser.add_argument("--commands", default="", help="Semicolon-separated recent commands")
parser.add_argument("--archive-file", default="", help="Archive file to delete (for cleanup action)")
args = parser.parse_args()
SESSIONS_DIR.mkdir(parents=True, exist_ok=True)
working_dir = os.path.abspath(args.working_dir)
if args.action == "init":
ap = active_path(working_dir)
if ap.exists():
existing = json.loads(ap.read_text())
if existing.get("checkpoints"):
arch = SESSIONS_DIR / f"archive_{workdir_hash(working_dir)}_{now_ts()}.json"
ap.rename(arch)
print(f"Archived previous session: {arch.name}")
session = {
"session_id": uuid.uuid4().hex[:8],
"tool": args.tool,
"working_dir": working_dir,
"start_time": now_iso(),
"last_active": now_iso(),
"status": "active",
"checkpoints": []
}
ap.write_text(json.dumps(session, indent=2, ensure_ascii=False))
print(f"Session initialized: {ap}")
elif args.action == "checkpoint":
ap = active_path(working_dir)
if not ap.exists():
print("No active session found. Run --action init first.")
return
session = json.loads(ap.read_text())
git = get_git_info(working_dir)
log_refs = get_log_refs(args.tool)
checkpoint = {
"timestamp": now_iso(),
"summary": args.summary,
"current_task": args.current_task,
"git_branch": git["branch"],
"git_recent_commits": git["recent_commits"],
"files": parse_files(args.files),
"key_decisions": [d.strip() for d in args.decisions.split(";") if d.strip()],
"open_questions": [q.strip() for q in args.open_questions.split(";") if q.strip()],
"recent_commands": [c.strip() for c in args.commands.split(";") if c.strip()],
"log_refs": log_refs
}
checkpoints = session.get("checkpoints", [])
checkpoints.append(checkpoint)
session["checkpoints"] = checkpoints[-MAX_CHECKPOINTS:]
session["last_active"] = now_iso()
ap.write_text(json.dumps(session, indent=2, ensure_ascii=False))
print(f"Checkpoint saved ({len(session['checkpoints'])}/{MAX_CHECKPOINTS})")
elif args.action == "cleanup":
target = Path(args.archive_file) if args.archive_file else None
if target and target.exists():
target.unlink()
print(f"Deleted transferred session: {target.name}")
else:
print("No archive file specified or file not found.")
if __name__ == "__main__":
main()