SLOPSHOPPER

replay-theater

Records the last turn's Edit and Write calls and steps through them one diff at a time.

newpanebandguardcommand
★ 55v0.1.0no licenseupdated 2026-10-24Esonhugh/claude-code/examples/mods/replay-theater
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · replay-theater
│ ┃ Replay Theater ✕ › fix the failing auth test and add an audit log call │ ┃ [1] 2 3 │ ┃ 1/3 /work/app/src/auth.ts ⏺ Read(src/auth.ts) │ ┃ + if (!claims || claims.exp < Date.now() / ⎿ Read 6 lines │ ┃ 1000) throw new Error('invalid token') ⏺ Update(src/auth.ts) │ ┃ + await audit('refresh', claims.sub) ⎿ Added 2 lines, removed 1 line │ ┃ - if (!claims) throw new Error('invalid ⏺ Bash(bun test) │ ┃ token') ⎿ 3 pass, 1 fail │ ┃ [ Prev ] [ Next ] [ Close ] │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /replay │ ⎿ replay-theater: Replaying │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Replay Theater
[1] 2 3 1/3 /work/app/src/auth.ts + if (!claims || claims.exp < Date.now() / 1000) throw new Error('invalid token') + await audit('refresh', claims.sub) - if (!claims) throw new Error('invalid token') [ Prev ] [ Next ] [ Close ]
README

Unofficial Claude Code

User Animated

基于 Claude Code 分发产物恢复的非官方 TypeScript/TSX 源码工作区,并持续维护本地 CLI、Agent、Workflow、OpenAI/Codex 兼容和调试能力。

本项目不是 Anthropic 官方产品、官方源码分发或官方 Claude Code release,也未获得 Anthropic 背书。公开包只分发 launcher 与对应平台二进制,不包含本仓库源码。

作者与维护者

  • 项目维护者:Esonhugh
  • 原始产品与上游实现:Anthropic Claude Code
  • 公开包:@esonhugh/claude-code

本仓库包含从公开 bundle/source map 恢复的上游代码和本地维护改动。上游归属与本地维护者身份应分别理解;完整本地变更以 CHANGELOG.md 为准。

项目概况

本项目主要用于:

  1. 保存从 Claude Code 分发产物恢复的可读 TypeScript/TSX 源码树。
  2. 提供可构建、可调试、可进行受控二次开发的本地 Claude Code CLI。
  3. 维护 Agent、Workflow、OpenAI/Codex、交互终端和会话命令等扩展。
  4. 保留恢复工程中的类型声明、stub 与 build shim,便于后续逐步替换或验证。
  5. 通过 binary-only 流程发布非官方 launcher,不公开分发本仓库源码。

开发环境与产物

  • 包管理器:Bun
  • Node.js:>=18
  • JavaScript 构建产物:dist/cli.js
  • 本地 binary:built-claude

当前发布版本为 2.1.294;发布历史、具体变更和验收边界统一记录在 CHANGELOG.md。

安装与运行

方式一:安装公开 launcher

公开包会根据 process.platform 和 process.arch 加载对应的平台二进制 optional dependency:

bun add --global @esonhugh/claude-code
claude --version

公开包仅包含 launcher 和平台二进制,不包含恢复源码。当前 release workflow 的普通平台包覆盖 Linux x64、Linux arm64、macOS arm64 和 Windows x64;不应假设其他 OS/architecture 组合(例如 macOS x64)已有产物。Linux 还会构建 baseline x64 和 musl 验证产物,但 baseline 不作为普通 npm 平台包。若当前平台没有对应 binary package,launcher 会返回缺少平台包的错误。

方式二:从源码构建

git clone <repository-url>
cd claude-code
bun install
make build
./built-claude --version

make build 构建平台 binary,并生成根目录下的 built-claude。

只构建 JavaScript CLI:

bun run build
bun ./dist/cli.js --version
bun ./dist/cli.js --help

主要功能

每项功能的作用、配置写法与可选值、不同配置下的终端效果见对应文档;完整索引见 docs/features/,变更历史见 CHANGELOG.md。设置写入用户级或项目级 settings.json,使用时与现有 JSON 合并。

领域功能说明
模型与 ProviderOpenAI / Codex provider把 Claude Code 的模型换成 OpenAI(GPT / Codex):用 ChatGPT 账号登录或 API key,工具、界面、命令照常使用
模型自动发现与 Gateway走自己的 API 网关或 OpenAI 兼容服务时,自动把网关提供的模型列进 /model,不用手打模型名
请求韧性:重试、回退模型与默认模型API 过载或断线时自动重试;主模型一直过载就换备用模型;可以让无人值守任务一直等到额度恢复
Effort 档位控制 Claude 每次「想多深」:低档快而省,高档慢而仔细;可全局设、按会话切,也可给子代理单独指定
Codex Apps用 ChatGPT 账号登录 OpenAI provider 时,直接使用 ChatGPT 里连好的 App(GitHub、Gmail 等):输入 @codex-app: 点名即可
Agent 与编排Agent 与后台任务Claude 把一部分工作交给「子代理」在后台做,你继续聊;可以查看、停止、续跑,也可以自己定义专用子代理
Agent Teams让 Claude 组一个小团队:一个负责人带几个队友并行干活,互相发消息、共享任务清单
Dynamic Workflow让 Claude 写一段编排脚本,一次调度多个子 Agent 分阶段、并行地干活;/workflows 看进度、保存成可复用的流程
背景会话、daemon 与 agents 视图把任务交给后台跑、终端立刻空出来;claude agents 一屏看谁在等你、谁在干活、谁做完了
独立会话通信同一台机器上开着的几个 Claude 会话可以互相发现、互发消息:一个写代码,一个做 review,完成了喊一声
权限与安全权限规则、权限模式与 Safe modeClaude 什么时候要先问你:用规则提前放行或禁止,用模式整体调松或调紧;受保护路径在任何模式下都会问
自动模式(Auto mode)不用一条条点确认:由分类器判断每个操作是否安全,安全的直接做,危险的拦下;可以用一句话加自己的规则
Hooks 事件、匹配与输出校验在 Claude 做某件事之前或之后自动跑你的脚本:拦住危险命令、改完自动格式化、开局注入上下文
Bash 沙箱让 Claude 跑的命令只能写项目目录、只能连你允许的域名、读不到你指定的密钥;开了以后命令可以不再逐条确认
工具Terminal 工具给 Claude 一个能持续交互的终端:开 REPL、操作 TUI、按 Ctrl+C,跑完自动通知
Monitor 工具让 Claude 在后台盯着日志或命令输出,有新内容就自动叫醒它,不用反复 sleep 轮询
定时任务:Cron、/loop 与 ScheduleWakeup让 Claude 按时间自动做事:「每 5 分钟看一下构建」「工作日早上 9 点汇总 PR」;/loop 一句话设定
终端程序状态(OSC 7501)支持的终端能在标签页上显示 Claude 正在忙、在等你批准、还是已经做完,不用切回来看
上下文与记忆记忆:auto memory 与 SQLite FTS5 检索Claude 把跨会话有用的事(项目约定、你的偏好)记到文件里,下次自动带上;可改成 SQLite 全文检索,按需查找
自动压缩窗口对话太长时把早期内容压成摘要;/autocompact 决定多长时压缩(按模型分别保存),/compact 手动压缩
Prompt context 与 Tool search让每次请求更省 token:提示词分层利于缓存,MCP 工具用到时才加载,大量同类工具只发一行汇总
用量、成本与状态/usage 看本次会话花了多少钱、用了多少 token;/stats 看长期使用习惯;恢复会话后成本继续累计
界面与交互会话命令本 fork 新增或补齐的斜杠命令:/recap 回顾、/cd 换目录、/focus 精简显示、/autocompact 调压缩时机、/usage 用量等
自主 Goal给 Claude 一个「做到才算完」的条件(如「所有测试通过」),每轮结束时自动检查,没达成就继续干
快捷键与 Vim 模式自己改快捷键、回合中途排队或立即插话、在输入框里用 Vim 的选择和括号跳转
自定义 UI / Branding 与 Status line改界面上显示的产品名、换掉 Logo 的 ASCII 图,用自己的脚本定制底部状态栏
/diff 视图全屏右侧 diff 面板:会话 / 未提交 / 分支三种基准、按轮次回看、把单个文件 diff 附到下一条提示
调试日志遇到问题时打开详细日志,看清楚 Claude Code 在后台做了什么、请求发到了哪
遥测与反馈默认不向 Anthropic 发送使用数据,/feedback 也随之隐藏;需要时一个环境变量打开
远程执行Direct Connect在一台机器上 claude server,在另一处用 claude cc://... 连过去:工具在 server 上执行,界面和权限确认在你这边
SSH Remoteclaude ssh 主机 让 Claude 在远程 Linux 机器上读写文件、跑命令,界面和权限确认留在本机;API 凭据不离开本机
插件与扩展插件与 Marketplace从插件市场一键安装命令、skill、agent、hook 和 MCP;团队可以自建 marketplace,装完 /reload-plugins 立即生效
MCP 配置与认证接入外部工具服务器(数据库、浏览器、内部 API 等);配置错一条只跳过那一条,/mcp 查看状态、重连、登录
Skills把一套做事步骤写进 SKILL.md,用 /名字 调用,或让 Claude 在合适时自动使用;/skill-doctor 找出从没用过却占提示的 skill
Channels让 Telegram、Discord 或你自己写的 MCP server 把消息推进正在运行的会话,还能在手机上远程批准工具调用
Mods / Function Hooks用一个 JS/TS 文件改造 Claude 本身:加命令、拦截或改写工具调用、在界面上加状态条和侧边面板(基线官方 2.1.303)
Mods 远程客户端(stream-json)给桌面 / IDE 等外部界面用的协议:通过 stream-json 让 Mod 把界面画到你自己的前端上,并把点击、输入传回来
Agent SDK 与 stream-json 控制协议在自己的程序里调用 Claude:官方 Agent SDK(TS / Python)可以直接驱动本 fork 的 CLI;补齐了 SDK 0.3 会用到的控制请求
VS Code 插件让官方 VS Code 插件改用本 fork 运行:面板里的 /status、/usage、/memory 等对话框都能正常打开
开发与发布构建、发布与终端验收工具给贡献者:怎么从源码构建出可运行的 binary、怎么和官方版本对照、发布流程做了什么

开发约定

修改 TypeScript 后至少运行:

bunx tsc --noEmit --pretty false
bun run build
bun run lint
bun run audit:missing
git diff --check

涉及 binary、CLI 入口或交互行为时,再运行:

make build
./built-claude --version
./built-claude --help

本项目仍包含恢复阶段的类型边界。修复类型时应优先使用精确 interface、discriminated union、unknown、assertion function 和 type guard,避免为消除局部错误而放宽全局核心类型。

技术文档(Ref)

入门与构建

架构

SSH、Workflow、研究与历史

研究和归档文档描述的是特定时间点,不应直接视为当前行为保证;当前使用方法以 README、docs/features/ 和源码为准,历史与验收记录以 CHANGELOG 为准。

安全与适用范围

本仓库用于授权的研究、调试和二次开发。恢复或修改后的 binary 不应未经独立安全、遥测、更新和权限审查直接用于生产环境。

请勿提交 .env、~/.codex/auth.json、API key、OAuth token、cookie、证书或其他私有配置。涉及外部 provider、插件、MCP、Workflow 和自动化任务时,应先确认权限范围及其对本地或共享环境的影响。

Source 2 files
hooks/replay-theater.mjs 161 lines
1// Replay Theater: step through the last turn's edits, one diff at a time.
2
3const PANE = "replay-theater";
4const EDIT_TOOLS = new Set(["Edit", "Write"]);
5const MAX_TEXT = 20_000;
6const MAX_DIFF_LINES = 40;
7
8// Held by the host, so the last replay survives a hot reload of this file.
9const replay = { plugin: "replay-theater", key: "replay" };
10const step = { plugin: "replay-theater", key: "step" };
11
12// Edits of the turn in progress; a reload mid-turn only loses this turn's partial record.
13let pending = [];
14
15export function register(on) {
16  // Observe: record the edit, never block or change it.
17  on("tool.call", async ($, e, next) => {
18    const steps = EDIT_TOOLS.has(e.tool) ? await stepsFor($, e) : [];
19    const result = await next(e); // the edit runs untouched
20    if (steps.length && !("deny" in result) && !result.isError) pending.push(...steps);
21    return result;
22  });
23
24  // Pair turn.start and turn.complete into one replay per turn; subagent turns stay out.
25  on("turn.start", ($, e, next) => {
26    if (!e.agentId) pending = [];
27    return next(e);
28  });
29
30  on("turn.complete", async ($, e, next) => {
31    const result = await next(e);
32    if (!e.agentId && pending.length) {
33      await $.state.set(replay, { steps: pending, seen: false });
34      await $.state.set(step, 0);
35      pending = [];
36    }
37    return result;
38  });
39
40  on("session.start", async ($, e, next) => {
41    const result = await next(e);
42    await $.command.register({ name: "replay", description: "Step through the last turn's file edits" });
43    return result;
44  });
45
46  on("command.run", { command: "replay" }, async ($) => ({ text: (await openReplay($)) ? "Replaying" : "No edits" }));
47
48  on("ui.render", { component: "AbovePrompt" }, async ($, e, next) => {
49    const { value } = await $.state.get(replay);
50    if (e.props.hasSurvey || !value || value.seen) return next(e);
51    const { Box, Text, Button } = $.ui.resolve(e);
52    const files = new Set(value.steps.map((s) => s.file)).size;
53    return Box({
54      flexDirection: "row",
55      paddingX: 1,
56      gap: 2,
57      children: [
58        Text({ color: "magenta", bold: true, children: "↺ Replay" }),
59        Text({ dimColor: true, children: `${value.steps.length} edit${value.steps.length === 1 ? "" : "s"} across ${files} file${files === 1 ? "" : "s"}` }),
60        Button({ key: "replay", label: "Replay", hotkey: "r", onPress: () => void openReplay($) }),
61      ],
62    });
63  });
64
65  on("ui.render", { component: "Pane", requestId: PANE }, async ($, e, next) => {
66    const { value } = await $.state.get(replay);
67    if (!value) return next(e);
68    const { value: at = 0 } = await $.state.get(step);
69    const index = Math.min(Math.max(at, 0), value.steps.length - 1);
70    const current = value.steps[index];
71    const { Box, Text, Button } = $.ui.resolve(e);
72    const go = (to) => () => void $.state.set(step, Math.min(Math.max(to, 0), value.steps.length - 1));
73    return Box({
74      flexDirection: "column",
75      paddingX: 1,
76      children: [
77        Text({
78          children: value.steps.map((_, i) => (i === index ? `[${i + 1}]` : ` ${i + 1} `)).join(""),
79        }),
80        Text({ bold: true, color: "magenta", children: `${index + 1}/${value.steps.length}  ${current.file}` }),
81        ...diff(current.before, current.after).map(([sign, line]) =>
82          Text({ color: sign === "+" ? "green" : sign === "-" ? "red" : undefined, dimColor: sign === " ", children: `${sign} ${line}` }),
83        ),
84        Box({
85          flexDirection: "row",
86          gap: 2,
87          children: [
88            Button({ key: "prev", label: "Prev", hotkey: "p", onPress: go(index - 1) }),
89            Button({ key: "next", label: "Next", hotkey: "n", onPress: go(index + 1) }),
90            Button({ key: "close", label: "Close", hotkey: "q", onPress: () => void $.ui.close({ id: PANE }) }),
91          ],
92        }),
93      ],
94    });
95  });
96}
97
98async function openReplay($) {
99  const { value } = await $.state.get(replay);
100  if (!value || value.steps.length === 0) return false;
101  await $.state.set(replay, { ...value, seen: true });
102  await $.state.set(step, 0);
103  // Placement is the surface's job: docked beside the transcript, or inline above the prompt.
104  await $.ui.open({ id: PANE, title: "Replay Theater", focus: true });
105  return true;
106}
107
108// For a Write, read the old contents just before the write lands, so the diff is real.
109async function stepsFor($, e) {
110  if (e.tool === "Edit") {
111    return [{ file: e.file_path, before: clip(e.old_string), after: clip(e.new_string) }];
112  }
113  let before = "";
114  try {
115    before = await $.fs.read(e.file_path);
116  } catch {
117    // a new file: nothing before it
118  }
119  return [{ file: e.file_path, before: clip(before), after: clip(e.content) }];
120}
121
122function clip(text) {
123  const value = String(text ?? "");
124  return value.length > MAX_TEXT ? `${value.slice(0, MAX_TEXT)}\n…` : value;
125}
126
127// A line diff by longest common subsequence, trimmed to the changed region.
128function diff(before, after) {
129  const a = before.split("\n");
130  const b = after.split("\n");
131  if (a.length * b.length > 250_000) {
132    return [...a.map((l) => ["-", l]), ...b.map((l) => ["+", l])].slice(0, MAX_DIFF_LINES);
133  }
134  const lcs = Array.from({ length: a.length + 1 }, () => new Array(b.length + 1).fill(0));
135  for (let i = a.length - 1; i >= 0; i -= 1) {
136    for (let j = b.length - 1; j >= 0; j -= 1) {
137      lcs[i][j] = a[i] === b[j] ? lcs[i + 1][j + 1] + 1 : Math.max(lcs[i + 1][j], lcs[i][j + 1]);
138    }
139  }
140  const out = [];
141  let i = 0;
142  let j = 0;
143  while (i < a.length || j < b.length) {
144    if (i < a.length && j < b.length && a[i] === b[j]) {
145      out.push([" ", a[i]]);
146      i += 1;
147      j += 1;
148    } else if (j < b.length && (i === a.length || lcs[i][j + 1] >= lcs[i + 1][j])) {
149      out.push(["+", b[j]]);
150      j += 1;
151    } else {
152      out.push(["-", a[i]]);
153      i += 1;
154    }
155  }
156  const first = out.findIndex(([s]) => s !== " ");
157  const last = out.length - 1 - [...out].reverse().findIndex(([s]) => s !== " ");
158  if (first === -1) return [[" ", "(no change)"]];
159  return out.slice(Math.max(0, first - 2), last + 3).slice(0, MAX_DIFF_LINES);
160}
161
types/index.d.ts 10 lines
1export type ReplayStep = { file: string; before: string; after: string };
2
3export type Replay = { steps: ReplayStep[]; seen: boolean };
4
5declare module "claude-code" {
6  interface PluginState {
7    "replay-theater": { replay: Replay; step: number };
8  }
9}
10