Lesson 0005 带你看完了整个"数据 → UI"的完整链路。但有一个问题:如果状态栏显示的结果不对——某个会话没出现、某个会话不该出现却出现了——你怎么调试?VS Code 扩展在 Extension Development Host 里运行,你没法在终端里 console.log 出来看。debug.ts 就是为了解决这个问题而存在的:它把核心逻辑复制出来,做成一个独立的 Node.js 脚本,让你在终端里看到扩展的完整决策过程。
src/debug.ts(273 行)、tsconfig.json(18 行)。
VS Code 扩展的运行环境和普通 Node.js 脚本有一个根本区别:
| extension.ts | debug.ts | |
|---|---|---|
| 运行环境 | VS Code Extension Host | Node.js(终端直接跑) |
| 入口 | activate() 函数 | 脚本顶层执行 |
| 输出 | 状态栏 UI + 隐式行为 | 终端文本 + 详细诊断 |
| 依赖 | vscode API(StatusBarItem 等) | 纯 Node.js(fs、path、os) |
| 使用场景 | 用户正常使用 | 开发者排查"为什么这个会话没显示" |
扩展内你不能随意 console.log——日志输出在 Extension Host 的开发者控制台里,需要手动打开"帮助 → 切换开发人员工具"才能看到。而 debug.ts 在终端里直接跑,每一行决策都以人类可读的格式打印出来。
debug.ts 中最引人注目的不是它做了什么,而是它的结构——文件前半部分是从 extension.ts 复制过来的类型定义和核心逻辑:
// debug.ts 文件结构 // // ============================================ // 第 18-38 行 — TYPES(从 extension.ts 复制) // TokenUsage, SessionInfo 接口定义 // // ============================================ // 第 44-138 行 — LOGIC(从 extension.ts 复制) // getClaudeProjectsDir() // getLatestTokenCount() — 完全相同的 90 行双向扫描 // // ============================================ // 第 144-268 行 — DEBUG RUNNER(debug.ts 独有的) // debugSessions() — 终端输出 + 超时会话分析 // // ============================================ // 第 271-272 行 — 入口 // 从 process.argv 读取可选的项目名过滤参数并发起调用
一个显而易见的质疑是:为什么不把共享逻辑提取到一个 shared.ts 里,让两边都 import?答案是:
// 如果提取共享模块…… // shared.ts → extension.ts ✅ 可以 import // shared.ts → debug.ts ❌ 也可以 import,编译产物在 out/ 里 // 实际上是可以的——两者都被 tsc 编译到 out/ // 但复制有复制的好处: // 1. debug.ts 可以脱离 VS Code 运行——不需要任何 VS Code 依赖 // 2. 调试逻辑和扩展逻辑可以独立演化 // 3. 避免引入一个"只为两个文件服务"的模块——不值得的抽象
虽然核心管线相同,但 debug 版本有三处重要的行为差异:
agent-*.jsonl// debug.ts 第 171 行 const files = fs.readdirSync(projectPath) .filter(f => f.endsWith('.jsonl') && !f.startsWith('agent-')) // ↑ 跳过 agent- 开头的文件
agent-*.jsonl 是 Claude Code 在某些场景下为子代理(sub-agent)创建的会话文件。它们和普通会话放在同一个目录下,但不是用户直接交互的会话——跳过它们是正确行为。
extension.ts 第 440 行从初始提交起就有 .filter(f => !f.startsWith('agent-'))——两个文件的过滤规则从一开始就是一致的。本课程的初版曾误判此处不一致,已在后续讨论中修正。这个误判本身恰好说明了 debug.ts 的存在价值:把内部决策过程摊开,让代码行为变得可验证——如果没有 debug.ts 的源码,你只能靠"相信文档"而不是"亲自核实"。
extension.ts 的决策是沉默的——会话要么出现在状态栏,要么不出现。debug.ts 告诉你为什么:
// debug.ts 的输出示例
========== CLAUDE CONTEXT BAR DEBUG ==========
Time: 2026-06-26T14:30:00.000Z
Claude dir: /Users/xiaos/.claude/projects
--- Project: d--teach-Claude-Context-Bar ---
Found 3 active session files
📄 6b4f4a7a...
Created: 2026-06-26T12:00:00.000Z
LastUpd: 2026-06-26T14:25:00.000Z
Tokens: 144000
Cleared: false
FirstMsg: "@lessons/0005-refresh-all-sessions.html..."
========== SUPERSESSION ANALYSIS ==========
Project: d--teach-Claude-Context-Bar
[✅ SHOW] 6b4f4a7a
[❌ HIDE] 729ca0cf
Reason: superseded by 6b4f4a7a (newer created after this one's last update)
Created: 2026-06-25T10:00:00.000Z
LastUpd: 2026-06-26T11:00:00.000Z
// debug.ts 第 271-272 行 const projectFilter = process.argv[2] || undefined; debugSessions(projectFilter); // 用法: // node out/debug.js → 分析所有项目 // node out/debug.js Context-Bar → 只看名称匹配 Context-Bar 的项目
debug.ts 不是扩展的一部分——它是独立的 Node.js 脚本。需要两步:
# 1. 编译 TypeScript → JavaScript(输出到 out/ 目录) npm run compile # 2. 用 Node.js 运行编译后的脚本 node out/debug.js # 所有项目 node out/debug.js Context-Bar # 只分析名称包含 "Context-Bar" 的项目
npm run compile 执行的是 tsc -p ./——读取 tsconfig.json,把 src/ 下的所有 .ts 文件编译为 out/ 下的 .js 文件。编译完成后 out/debug.js 和 out/extension.js 都在同一个目录下。
debug.ts 中的超时会话分析是 extension.ts 中同一逻辑的带注释版本——但它把决策过程展开为终端输出:
// 第 211-261 行:超时会话分析 const projectGroups = new Map<string, SessionInfo[]>(); for (const session of allSessions) { const base = session.projectName; if (!projectGroups.has(base)) projectGroups.set(base, []); projectGroups.get(base)!.push(session); } for (const [baseName, group] of projectGroups) { // 1. 按创建时间从新到老排序 group.sort((a, b) => (b.sessionCreated?.getTime() || 0) - (a.sessionCreated?.getTime() || 0)); for (let i = 0; i < group.length; i++) { const session = group[i]; let status = "✅ SHOW"; let reason = ""; // 2. 规则 1:/clear 结束的会话 → 隐藏 if (session.wasCleared) { status = "❌ HIDE"; reason = "wasCleared=true (ended with /clear)"; } else { // 3. 规则 2:如果任何更「新」创建的会话 // 的创建时间 > 当前会话的最后更新时间 → 当前会话被取代 for (let j = 0; j < i; j++) { const newerSession = group[j]; const newerCreated = newerSession.sessionCreated?.getTime() || 0; const thisLastUpdated = session.lastUpdated.getTime(); if (newerCreated > thisLastUpdated) { status = "❌ HIDE"; reason = `superseded by ${newerSession.sessionId} … `; break; } } } // 4. 输出:每条会话都打印判定结果和原因 console.log(` [${status}] ${session.sessionId}`); if (reason) console.log(` Reason: ${reason}`); } }
这个算法和 Lesson 0004 第 6 节完全一致——「如果老会话在新会话创建后没有再活动,就被淘汰」。区别只在于 debug.ts 把判断过程打印出来。
SessionInfo[];在 debug.ts 中返回的是「每条会话 + 决策原因」的可读报告。当状态栏显示和预期不符时,debug.ts 就是你的第一站。
debug.ts 数据流 process.argv[2](可选的项目过滤参数) │ ▼ getClaudeProjectsDir() ┌──────────────────┐ │ os.homedir() │ │ + /.claude/ │ │ projects/ │ └──────┬───────────┘ │ ▼ 扫描项目目录 ┌──────────────────┐ │ 遍历 frojectDirs │ │ 过滤:mtime > │ │ now - 5min │ │ 过滤:!agent-* │ ← 只在这里有! │ 排序:新 → 老 │ └──────┬───────────┘ │ ▼ getLatestTokenCount() ← 从 extension.ts 复制的 90 行 ┌──────────────────┐ │ readFileSync │ │ 反向扫 /clear │ │ 正向扫元数据 │ └──────┬───────────┘ │ ▼ TokenUsage{} │ ▼ 分组 + 超时会话分析 ┌──────────────────┐ │ 按 projectName │ │ 分组 │ │ 按 sessionCreated │ │ 从新到老排序 │ │ │ │ for each session: │ │ wasCleared? │ │ → ❌ HIDE │ │ │ │ 新会话创建时间 │ │ > 老会话 │ │ 最后更新时间? │ │ → ❌ HIDE │ │ │ │ 否则 → ✅ SHOW │ └──────┬───────────┘ │ ▼ ┌───────────────────────┐ │ [✅ SHOW] 6b4f4a7a │ │ [❌ HIDE] 729ca0cf │ │ Reason: superseded │ │ by 6b4f4a7a... │ └───────────────────────┘
你可能在想:debug.ts 这种"独立调试管线"是大家都这么写,还是这个项目自己发明的?答案是:思想是通用的,具体的边界选择是项目特定的。
把核心逻辑从受限运行环境中剥离,放到一个可以自由打印的独立脚本里——这个模式在软件工程的各个领域都有对应:
| 领域 | 叫什么 | 做什么 |
|---|---|---|
| VS Code 扩展 | Standalone debug script | 绕过 Extension Host,在终端直接跑核心逻辑 |
| 嵌入式固件 | Host-based test harness | 在 PC 上模拟固件逻辑,不在微控制器上盲调 |
| 游戏引擎 | Debug console / replay system | 把游戏逻辑抽出来,脱离渲染引擎单独调试 |
| 编译器 | Standalone pass runner | 单独跑某一个编译 pass,不跑整个 pipeline |
| 浏览器扩展 | Background script debugger | 把 Service Worker 逻辑抽到 Node.js 里调试 |
共同点:运行环境太受限 → 把逻辑剥离/复制出来 → 在自由环境里看内部决策过程。
这个模式中,可迁移的是判断框架,不可迁移的是具体的边界选择——在哪里划 150 行的线,取决于你的具体上下文:
| 因素 | 倾向于复制 | 倾向于提取共享模块 |
|---|---|---|
| 共享逻辑规模 | < 200 行 | > 300 行 |
| 消费者数量 | 2 个 | 3+ 个 |
| 演化方向 | 两边可能独立演化(如 debug.ts 的 SessionInfo 故意更简单) | 所有消费者需要完全相同的接口 |
| 测试覆盖 | 无测试——复制更安全(没安全网时不想引入新模块) | 有自动化测试——提取后敢改 |
| 模块成本 | 复制 150 行 < 引入 50 行胶水 + 一个模块边界 | 共享逻辑足够大,胶水代码的固定成本被摊薄 |
对于这个项目:~150 行共享逻辑、2 个消费者、没有自动化测试、两边已有细微的分叉(SessionInfo 字段数不同)——复制是当前的最优解。但如果你为它写好了单元测试(见下一节),这个方程就会翻转——提取共享模块变成更安全的选择。
debug.ts 解决的是可观测性("行为为什么是这样?"),但它完全缺失了另一维度:自动化验证("行为还正确吗?")。当前项目没有任何自动化测试——每次改完代码,正确性完全依赖人眼检查。以下是按投入产出比从高到低排列的改进路线:
这个项目的核心逻辑天然适合单元测试——它们不依赖 VS Code API:
| 函数 | 输入 | 输出 | VS Code 依赖 |
|---|---|---|---|
getLatestTokenCount() | .jsonl 文件路径 | TokenUsage 结构体 | 零——只用 fs |
| Supersession 算法 | SessionInfo[] | 过滤后的 SessionInfo[] | 零 |
decodeProjectPath() | "C--dev-my-project" | {name, fullPath} | 零 |
getContextLimitForModel() | "claude-sonnet-4-6-1m" | 1000000 | 零 |
测试方法:手写一组小型 .jsonl fixture 文件,覆盖所有边界情况,然后用 Node 原生 node:test(零依赖)或 Vitest 断言输出。这些测试跑在毫秒级——从"每次改代码手动跑 node out/debug.js 再人眼扫输出"变成"改完代码跑 npm test,3 秒出结果"。
// 应该覆盖的 fixture 场景(举例) // clear-no-new-messages.jsonl → wasCleared = true // clear-then-new-message.jsonl → wasCleared = false // empty-file.jsonl → totalTokens = 0(不崩溃) // no-usage-entry.jsonl → 旧格式会话,totalTokens = 0 // agent-subagent.jsonl → 确认 agent- 过滤正确
有了单元测试做安全网,你可以安全地把复制的 ~150 行提取为 shared.ts——测试覆盖了正确性,extension.ts 和 debug.ts 消费同一个真相来源时不会互相拖累。这正是上一节 7.2 表格中"测试覆盖"那一行的含义:没有测试前,复制更安全;有了测试后,提取更合理。
构造一个已知的 .claude/projects/ 目录结构作为 fixture,重定向 getClaudeProjectsDir() 指向它,运行 debugSessions(),对输出做 snapshot diff——任何意外的输出变化都会在 CI 中被捕获。
VS Code 官方 @vscode/test-electron 可以在真实 Extension Host 中运行测试。但对于这个项目,边际收益很低——核心逻辑是纯数据变换,单元测试已覆盖 90% 风险。集成测试每次都要启动一个 VS Code 实例,慢且 flaky。
npm run compile && node out/debug.js,观察自己所有项目的诊断输出——是否有 ❌ HIDE 的会话?原因是什么?node out/debug.js teach),对比全量输出——确认过滤逻辑生效debug.ts,对比 getLatestTokenCount() 和 extension.ts 中的同名函数——找出两处代码差异(提示:关注 firstMessage 的解析逻辑和 SessionInfo 的字段数量),推测为什么 debug.ts 可以有更简单的版本debug.ts 第 171 行把 !f.startsWith('agent-') 改成 f.startsWith('agent-')(即反过来只看 agent 文件),编译运行——你的项目中有 agent-*.jsonl 文件吗?这个过滤在 extension.ts 第 440 行也同样存在——如果两处不一致,debug.ts 的诊断输出还能信任吗?getLatestTokenCount() 提取到 shared.ts,需要改哪些文件?修改量和复制 90 行相比如何?然后阅读 第 7 节 的表格,确认你的判断和表格中的条件是否一致getLatestTokenCount() 设计 3 个 fixture 场景——参考 第 8 节 的 pre 块中的示例debug.ts 和 extension.ts 共享相同的核心逻辑(getLatestTokenCount),但代码被复制而非提取为共享模块。主要权衡是什么?
debug.ts 过滤 agent-*.jsonl 文件的原因是什么?
超时会话分析中,同项目的会话按什么顺序排列?
为什么 debug.ts 的入口代码从 process.argv[2] 读取参数,而不是像 extension.ts 那样从 VS Code 配置读取?
debug.ts 的诊断输出中,❌ HIDE 的判定有三种可能性——以下哪一项不会导致 HIDE?
outDir、rootDir、module 等关键编译选项shared.ts 在实际操作中会遇到什么坑?这些问题都可以继续追问。