Lesson 0006 debug.ts:独立调试管线

Lesson 0005 带你看完了整个"数据 → UI"的完整链路。但有一个问题:如果状态栏显示的结果不对——某个会话没出现、某个会话不该出现却出现了——你怎么调试?VS Code 扩展在 Extension Development Host 里运行,你没法在终端里 console.log 出来看。debug.ts 就是为了解决这个问题而存在的:它把核心逻辑复制出来,做成一个独立的 Node.js 脚本,让你在终端里看到扩展的完整决策过程。

📌 本节课对应代码:src/debug.ts(273 行)、tsconfig.json(18 行)。

1. 为什么需要一个独立的调试脚本

VS Code 扩展的运行环境和普通 Node.js 脚本有一个根本区别:

extension.tsdebug.ts
运行环境VS Code Extension HostNode.js(终端直接跑)
入口activate() 函数脚本顶层执行
输出状态栏 UI + 隐式行为终端文本 + 详细诊断
依赖vscode API(StatusBarItem 等)纯 Node.js(fspathos
使用场景用户正常使用开发者排查"为什么这个会话没显示"

扩展内你不能随意 console.log——日志输出在 Extension Host 的开发者控制台里,需要手动打开"帮助 → 切换开发人员工具"才能看到。而 debug.ts 在终端里直接跑,每一行决策都以人类可读的格式打印出来

🎯 这不是测试——这是可观测性。测试验证"行为是否正确",而 debug.ts 回答的是"行为为什么是这样"。它打印出超时会话的判定依据、每个会话被显示/隐藏的原因——这些信息在正常运行时被有意隐藏,但在调试时不可替代。

2. 架构:刻意为之的代码复制

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. 避免引入一个"只为两个文件服务"的模块——不值得的抽象
🎯 代码复制不是绝对的恶——关键看上下文。当两个模块共享相同的生命周期(同一个 tsc 编译产物)且共享逻辑只有 ~150 行时,复制比提取更简单。提取共享模块会引入模块解析路径、循环依赖风险、以及"为修改一个模块而不得不动另一个模块"的心智负担。这条边界在 150 行复制 vs 50 行胶水代码(import/export/路径配置)时是划算的——超过 300 行就不一定了。

3. debug.ts 与 extension.ts 的关键差异

虽然核心管线相同,但 debug 版本有三处重要的行为差异:

3.1 过滤 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)创建的会话文件。它们和普通会话放在同一个目录下,但不是用户直接交互的会话——跳过它们是正确行为。

🔍 经 git blame 核实:extension.ts 第 440 行从初始提交起就有 .filter(f => !f.startsWith('agent-'))——两个文件的过滤规则从一开始就是一致的本课程的初版曾误判此处不一致,已在后续讨论中修正。这个误判本身恰好说明了 debug.ts 的存在价值:把内部决策过程摊开,让代码行为变得可验证——如果没有 debug.ts 的源码,你只能靠"相信文档"而不是"亲自核实"。

3.2 详细的诊断输出

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

3.3 支持项目名过滤参数

// debug.ts 第 271-272 行
const projectFilter = process.argv[2] || undefined;
debugSessions(projectFilter);

// 用法:
//   node out/debug.js                      → 分析所有项目
//   node out/debug.js Context-Bar          → 只看名称匹配 Context-Bar 的项目

4. 运行方式

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.jsout/extension.js 都在同一个目录下。

5. 超时会话分析的完整逻辑

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 把判断过程打印出来

🎯 调试脚本的价值不在"新逻辑",而在"可见性"。同一个算法,在 extension.ts 中返回的是一个过滤后的 SessionInfo[];在 debug.ts 中返回的是「每条会话 + 决策原因」的可读报告。当状态栏显示和预期不符时,debug.ts 就是你的第一站。

6. 完整数据流

                          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... │
              └───────────────────────┘

7. 通用模式 vs 项目特定

你可能在想:debug.ts 这种"独立调试管线"是大家都这么写,还是这个项目自己发明的?答案是:思想是通用的,具体的边界选择是项目特定的

7.1 通用模式:可观测性管线(Observability Harness)

把核心逻辑从受限运行环境中剥离,放到一个可以自由打印的独立脚本里——这个模式在软件工程的各个领域都有对应:

领域叫什么做什么
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 里调试

共同点:运行环境太受限 → 把逻辑剥离/复制出来 → 在自由环境里看内部决策过程。

7.2 项目特定的:代码复制 vs 提取共享模块的边界

这个模式中,可迁移的是判断框架,不可迁移的是具体的边界选择——在哪里划 150 行的线,取决于你的具体上下文:

因素倾向于复制倾向于提取共享模块
共享逻辑规模< 200 行> 300 行
消费者数量2 个3+ 个
演化方向两边可能独立演化(如 debug.ts 的 SessionInfo 故意更简单)所有消费者需要完全相同的接口
测试覆盖无测试——复制更安全(没安全网时不想引入新模块)有自动化测试——提取后敢改
模块成本复制 150 行 < 引入 50 行胶水 + 一个模块边界共享逻辑足够大,胶水代码的固定成本被摊薄

对于这个项目:~150 行共享逻辑、2 个消费者、没有自动化测试、两边已有细微的分叉(SessionInfo 字段数不同)——复制是当前的最优解。但如果你为它写好了单元测试(见下一节),这个方程就会翻转——提取共享模块变成更安全的选择。

💡 这个判断框架来自两个经典来源:Sandi Metz 的 "The Wrong Abstraction" 解释了为什么"提取共享代码"有时是更差的选择;John Ousterhout 的 A Philosophy of Software Design 第 9 章讨论了"模块应该深还是浅"——和 150 行的阈值直接相关。

8. 从调试到测试——后续改进方向

debug.ts 解决的是可观测性("行为为什么是这样?"),但它完全缺失了另一维度:自动化验证("行为还正确吗?")。当前项目没有任何自动化测试——每次改完代码,正确性完全依赖人眼检查。以下是按投入产出比从高到低排列的改进路线:

8.1 Level 1:纯逻辑单元测试(投入产出比最高)

这个项目的核心逻辑天然适合单元测试——它们不依赖 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- 过滤正确

8.2 Level 2:提取 shared.ts(依赖 Level 1)

有了单元测试做安全网,你可以安全地把复制的 ~150 行提取为 shared.ts——测试覆盖了正确性,extension.tsdebug.ts 消费同一个真相来源时不会互相拖累。这正是上一节 7.2 表格中"测试覆盖"那一行的含义:没有测试前,复制更安全;有了测试后,提取更合理。

8.3 Level 3:debug.ts 的 Snapshot 测试

构造一个已知的 .claude/projects/ 目录结构作为 fixture,重定向 getClaudeProjectsDir() 指向它,运行 debugSessions(),对输出做 snapshot diff——任何意外的输出变化都会在 CI 中被捕获。

8.4 Level 4:VS Code 集成测试(收益递减)

VS Code 官方 @vscode/test-electron 可以在真实 Extension Host 中运行测试。但对于这个项目,边际收益很低——核心逻辑是纯数据变换,单元测试已覆盖 90% 风险。集成测试每次都要启动一个 VS Code 实例,慢且 flaky。

9. 练习

  1. 运行 npm run compile && node out/debug.js,观察自己所有项目的诊断输出——是否有 ❌ HIDE 的会话?原因是什么?
  2. 传入一个项目名过滤参数(如 node out/debug.js teach),对比全量输出——确认过滤逻辑生效
  3. 打开 debug.ts,对比 getLatestTokenCount()extension.ts 中的同名函数——找出两处代码差异(提示:关注 firstMessage 的解析逻辑和 SessionInfo 的字段数量),推测为什么 debug.ts 可以有更简单的版本
  4. debug.ts 第 171 行把 !f.startsWith('agent-') 改成 f.startsWith('agent-')(即反过来只看 agent 文件),编译运行——你的项目中有 agent-*.jsonl 文件吗?这个过滤在 extension.ts 第 440 行也同样存在——如果两处不一致,debug.ts 的诊断输出还能信任吗?
  5. 体会"为什么不做共享模块"——如果要把 getLatestTokenCount() 提取到 shared.ts,需要改哪些文件?修改量和复制 90 行相比如何?然后阅读 第 7 节 的表格,确认你的判断和表格中的条件是否一致
  6. (延伸)如果你要为这个项目添加自动化测试,你会先测哪个函数?为 getLatestTokenCount() 设计 3 个 fixture 场景——参考 第 8 节 的 pre 块中的示例

10. 知识检查

问题 1

debug.tsextension.ts 共享相同的核心逻辑(getLatestTokenCount),但代码被复制而非提取为共享模块。主要权衡是什么?

问题 2

debug.ts 过滤 agent-*.jsonl 文件的原因是什么?

问题 3

超时会话分析中,同项目的会话按什么顺序排列?

问题 4

为什么 debug.ts 的入口代码从 process.argv[2] 读取参数,而不是像 extension.ts 那样从 VS Code 配置读取?

问题 5

debug.ts 的诊断输出中,❌ HIDE 的判定有三种可能性——以下哪一项不会导致 HIDE?

11. 推荐深入阅读

💡 提问提示:debug.ts 揭示了扩展架构的一个重要维度——可观测性。想把 debug.ts 的超时会话分析改成一个 VS Code 命令面板(Command Palette)里可直接调用的"诊断报告"吗?想为这个项目添加自动化单元测试(见 第 8 节)吗?想看看提取 shared.ts 在实际操作中会遇到什么坑?这些问题都可以继续追问。