Lesson 0004 findActiveSessions:数据引擎

前三课我们学会了扩展怎么说package.json)、怎么活生命周期)、怎么画StatusBarItem)。这一课深入扩展的数据心脏——findActiveSessions()。它回答了扩展存在的最核心问题:"现在有哪些活跃的 Claude Code 会话?"

📌 本节课对应代码:extension.ts 第 293–578 行。包含 getLatestTokenCount()getContextLimitForModel()findActiveSessions() 及其内部的稳定编号和手动隐藏逻辑。

1. 它在整个扩展中的位置

         文件系统 (~/.claude/projects/)
                      │
                      ▼
           findActiveSessions()   ← 本课
                      │
                      ▼
              SessionInfo[]       ← 结构化数据
                      │
                      ▼
           refreshAllSessions()   ← Lesson 0003
                      │
                      ▼
             StatusBarItem[]      ← 状态栏 UI

findActiveSessions() 是纯粹的数据函数——它不操作任何 UI,只做一件事:从文件系统中提取结构化数据。它返回的 SessionInfo[]refreshAllSessions() 的唯一输入。

2. 八步数据管线

一次 findActiveSessions() 调用经过八个步骤,每一步将原始数据向前推进一层:

Step 1: 扫描     ~/.claude/projects/ 下的目录和 .jsonl 文件
Step 2: 解码     将编码后的目录名还原为项目路径和名称
Step 3: 解析     逐行读取 JSONL,提取最新的 token 用量和元数据
Step 4: 定限     根据模型类型确定上下文窗口大小
Step 5: 过滤     剔除超时、已清除、被覆盖的会话
Step 6: 编号     同项目多会话分配稳定编号(webapp, webapp-2, …)
Step 7: 隐藏     处理用户手动隐藏的会话,检测是否有新活动需重新显示
Step 8: 截断     最多返回 5 条,防止状态栏被海量会话淹没

3. Step 1:扫描文件系统

const claudeDir = getClaudeProjectsDir();
// → ~/.claude/projects/

const cutoffTime = Date.now() - (idleTimeout * 1000);
// idleTimeout 默认 180 秒——只关心最近 3 分钟内有活动的会话

扫描逻辑遍历 ~/.claude/projects/ 下的每个子目录:

  1. 跳过不是目录的条目
  2. 跳过 claude-pluginsclaude-mem(后台进程,不是交互会话)
  3. 找目录下以 .jsonl 结尾的文件
  4. 跳过 agent- 前缀的文件(后台 agent)
  5. 只保留 mtimecutoffTime 内的文件
  6. 按最后修改时间从新到老排序
🎯 为什么在文件系统层就做时间过滤?如果不过滤,每次刷新都要解析所有历史会话文件——包括几个月前的。早期过滤大幅减少解析工作量。这是数据管线设计的一条通用原则:在尽量早的阶段丢弃不需要的数据

4. Step 2:解码项目路径

Claude Code 把项目路径编码进了目录名。规则是:路径分隔符变成单横线,Windows 盘符的冒号变成双横线。

编码规则                             解码结果
C--dev-webapp             →    C:\dev\webapp
C--dev-tools-my-plugin    →    C:\dev\tools\my-plugin
-Users-ed-work-api        →    /Users/ed/work/api

decodeProjectPath() 的工作分两步:

  1. 检测操作系统:第一个分段是单个字母(如 C)→ Windows 路径;否则 → Unix 路径
  2. 提取项目名:取路径的最后 2–3 段(跳过盘符和顶层目录如 Users),用横线连接
C:\dev\my-cool-project   →  parts = ['C', 'dev', 'my-cool-project']
                          →  取后 3 段 → 'dev', 'my-cool-project'
                          →  projectName = 'dev-my-cool-project'

/Users/ed/work/api        →  parts = ['Users', 'ed', 'work', 'api']
                          →  取后 3 段 → 'ed', 'work', 'api'
                          →  projectName = 'ed-work-api'
⚠️ 限制:decodeProjectPath() 使用启发式算法——它不能 100% 区分"路径中的横线"和"项目名中的横线"(如 my-cool-project 中的横线)。最终的项目名是近似值,而非精确路径重建。

5. Step 3:解析 token 数据

对每个入选的 .jsonl 文件,调用 getLatestTokenCount()。这是整个管线中最耗时的步骤——它把整个文件读进内存并逐行解析 JSON。

5.1 JSONL 格式

JSONL(JSON Lines)是一种行式数据格式:文件的每一行都是一个独立、完整的 JSON 对象。它和我们熟悉的 .json 有两个本质区别:(1) .json 文件内是一个整体——最外层必须是对象或数组,每次追加数据需要重写整个文件;(2) JSONL 没有最外层包裹——每一行独立存在,追加一行只需 append 到文件末尾,O(1) 操作。

Claude Code 选择 JSONL 正是因为它天然适合持续增长的对话流——每条消息一行,从不修改历史,崩溃时最多损坏最后一行。完整的对比与常见陷阱见 参考:数据格式速查

每行一个独立的 JSON 对象,Claude Code 的完整消息流:
{"type":"user","message":{"content":"帮我写一个函数","timestamp":"..."}}
{"type":"assistant","message":{"content":"好的……","model":"claude-sonnet-4-6","usage":{"input_tokens":42,…}}}
{"type":"user","message":{"content":"/clear","timestamp":"..."}}
{"type":"user","message":{"content":"现在重新开始……","timestamp":"..."}}
... 更多行 ...

5.2 从后往前扫描:检测 /clear

关键设计:解析器从文件最后一行往前扫,找最近一次 /clear 命令。判断一个会话是否"已清除"需要同时满足两个条件:

  1. 文件中存在 /clear 命令
    (检测:msgContent.includes('<command-name>/clear</command-name>')
  2. /clear 之后没有任何用户消息
    (意味着用户清了上下文但还没继续用这个会话)
// 扫描逻辑:从最后一行往前扫
let lastClearIndex = -1;
let userMessagesAfterClear = 0;

for (i = lastLine → firstLine) {
    if (是 /clear) { lastClearIndex = i; break; }
    if (是用户消息) { userMessagesAfterClear++; }
}
// wasCleared = (找到了 /clear) && (clear 之后没有用户消息)
🔀 没找到 /clear 时呢?这是最简单的路径——反向扫描跑完整个文件,lastClearIndex 保持 -1wasCleared 直接为 falsestartIndex = 0,正向扫描从第一行开始,遍历整个文件收集元数据。不需要跳过任何东西。可以把 /clear 理解为文件中的一个书签——没书签就从封面读到封底,有书签就只读书签之后的部分。

5.3 从前向后扫描:提取元数据

确定 /clear 位置后,从该位置往后扫描,收集四项信息:

字段提取逻辑用途
sessionCreated第一条有效记录的 timestamp判断会话创建时间,用于稳定编号
firstMessage第一条用户消息的前 60 个字符(跳过命令)在 tooltip 中显示,帮助用户识别会话
model最后一条包含 model 字段的记录显示在 tooltip 中,也用于确定上下文限制
totalTokens最后一条包含 usage 的记录计算百分比、背景色
// token 用量的计算(第 384 行)
totalTokens = input_tokens + cache_read_input_tokens + cache_creation_input_tokens
// 三项都是"输入 token"——Claude Code 的上下文用量就是已消耗的输入 token
🔑 为什么要从后往前扫 /clear,再从前扫数据?两次扫描的原因不同:
反向扫描:找到最近的 /clear。从后往前扫,遇到的第一个 /clear 就是最近的。
正向扫描:从 /clear 之后开始,按时间顺序收集 sessionCreated(取最早)、firstMessage(取最早)、totalTokens(取最新)。

6. Step 4:确定上下文限制

一个会话的百分比 = totalTokens / contextLimit × 100%。上下文限制取决于模型:

function getContextLimitForModel(model, userLimit) {
    if (model 包含 'sonnet' 且 包含 '1m') → 1,000,000
    其他所有模型                        → userLimit(默认 200,000)
}

截至 2026 年 6 月,支持 1M 上下文的模型已从最初的 Sonnet 4.5 扩展到 Opus 4.6、Opus 4.8、Sonnet 4.6 等,并已达到 GA(General Availability)。其他模型(Haiku、早期 Sonnet/Opus 标准版)仍走用户配置的默认值。

⚠️ 代码中的模型检测缺口:当前代码只匹配包含 'sonnet''1m' 的模型名。这意味着 Opus 4.6/4.8 的 1M 变体不会被识别——如果 Opus 1M 会话的 token 用量超过 200K,会算出超过 100% 的百分比。这是一个真实工程中的常见问题:硬编码的模型能力假设随版本迭代而失效。更健壮的方案是维护模型→上下文的映射表,或通过 API 查询模型元信息——但都意味着额外的维护成本。

7. Step 5:过滤淘汰的会话

这是管线中逻辑最复杂的步骤。同一项目的会话被分组,组内按创建时间从新到老排序,然后逐一判断是否淘汰:

7.1 已清除的会话

if (session.wasCleared) → continue;  // 直接跳过

7.2 被新会话覆盖的会话

核心问题:同一个项目可以同时存在多个会话文件。过滤要回答的是——哪些会话还"活着",哪些已经被用户放弃了?

判断标准:存在一个更新的会话,其创建时间晚于该旧会话的最后更新时间

以下用可视化时间线说明。假设 webapp 项目有 A、B、C 三个会话:

                    时间轴 →→→

10:00 ─────────────────────────────────────────── 15:00
 │                                                        │
 │  A ████████████████████████████████░░░░░░░░░░░░░░░░░░  │
 │    ↑ 创建 10:00                    ↑ 最后活动 12:30    │
 │                                                        │
 │  B                 ████████████████████████░░░░░░░░░░  │
 │                    ↑ 创建 12:00       ↑ 最后活动 14:00 │
 │                                                        │
 │  C                                    ███████████████  │
 │                                       ↑ 创建 14:30     │
 │                                       ↑ 最后活动 14:50 │

审 C(最新创建 14:30)
  前面没有更新的会话 → 不会被任何人淘汰 → ✅ 保留

审 B(创建 12:00,最后活动 14:00)
  前面有 C。C 的出生时间 14:30 > B 的最后活动 14:00?
  → 是。C 出生时 B 已经死了 30 分钟 → 🗑️ 淘汰

审 A(创建 10:00,最后活动 12:30)
  前面有 C。C 的出生时间 14:30 > A 的最后活动 12:30?
  → 是。C 出生时 A 已经死了 2 小时 → 🗑️ 淘汰

但如果用户在 C 创建之后又回去用了 B,情况就不同了。将 B 的最后活动改为 14:45:

审 B(创建 12:00,最后活动 14:45)
  前面有 C。C 的出生时间 14:30 > B 的最后活动 14:45?
  → 否。C 出生后 B 还在动 → ✅ 保留(并行使用)
一句话总结:新会话的出生时间 > 老会话的死亡时间 → 老会话被放弃。这就是代码中 newerCreated > thisLastUpdated 的含义。新会话出生时老会话已经不动了——说明用户开了新的,老的不要了。但如果新会话出生后老会话还有活动——说明用户在两个会话之间来回切换,两个都要保留。

8. Step 6:稳定编号

过滤后,组内的活跃会话按创建时间从老到新排序。最老的会话保留原名,后续的加数字后缀:

活跃会话(按创建时间排序):
sessionA (最老) → projectName = 'webapp'
sessionB        → projectName = 'webapp-2'
sessionC (最新) → projectName = 'webapp-3'

编号是稳定的——基于 sessionCreated 时间戳,不会随刷新顺序变化。最老的永远是原始名。

9. Step 7:手动隐藏与自动取消隐藏

用户点击状态栏条目时,hideSession 命令将 sessionFile 和时间戳存入 hiddenSessions Map。每次刷新时,findActiveSessions() 检查:

const hiddenAt = hiddenSessions.get(session.sessionFile);
if (hiddenAt) {
    if (session.lastUpdated > hiddenAt) {
        // 隐藏后又有新活动 → 自动取消隐藏
        hiddenSessions.delete(session.sessionFile);
        return true;
    }
    return false;  // 仍然隐藏
}

这个逻辑确保了一个合理的行为:用户隐藏了一个会话,但如果它后来又有活动了(用户可能又用起了这个项目),就自动重新显示。如果用户只是点了一下隐藏但没再使用那个会话,它就一直不出现。

10. Step 8:5 条上限

return visibleSessions.slice(0, 5);

最终返回最多 5 条。这是硬编码的——防止用户同时开了几十个会话把状态栏塞满。配合排序(按 lastUpdated 从新到老),这意味着最近活跃的 5 个会话会显示在状态栏中。

🎯 设计权衡:5 条不是可配置的——代码中没有对应的 settings 项。这是"有意的限制":状态栏不是仪表盘,5 条以上就开始抢占注意力。如果需要看所有会话,那是 TreeView 或 WebviewPanel 的功能范围。

11. 完整数据流图

~/.claude/projects/
│
├─ C--dev-webapp/
│   ├─ abc123.jsonl  ──→  getLatestTokenCount()  ──→  {totalTokens:144K, model:'sonnet-4.6', …}
│   └─ def456.jsonl  ──→  …                      ──→  {totalTokens:86K, …}
│
├─ -Users-ed-api/
│   └─ ghi789.jsonl  ──→  …                      ──→  {totalTokens:200K, …}
│
└─ C--dev-docs/
    └─ jkl012.jsonl  ──→  … (mtime 超时 → 跳过)

            │
            ▼
    ┌───────────────────┐
    │ 按 projectName 分组 │
    │ "webapp" → [A, B]  │
    │ "api"    → [C]     │
    └──────┬────────────┘
           │
           ▼
    ┌───────────────────┐
    │ 组内过滤           │
    │ · wasCleared? 跳过 │
    │ · 被覆盖? 淘汰     │
    └──────┬────────────┘
           │
           ▼
    ┌───────────────────┐
    │ 稳定编号           │
    │ A → "webapp"       │
    │ B → "webapp-2"     │
    │ C → "api"          │
    └──────┬────────────┘
           │
           ▼
    ┌───────────────────┐
    │ 手动隐藏过滤        │
    │ + auto-unhide      │
    └──────┬────────────┘
           │
           ▼
    ┌───────────────────┐
    │ slice(0, 5)        │
    │ 按 lastUpdated 排序 │
    └──────┬────────────┘
           │
           ▼
    SessionInfo[]  ──→  refreshAllSessions()

12. 练习:追踪一个会话的数据旅程

  1. 打开 extension.ts,找到 findActiveSessions()(第 409 行)
  2. 在纸上画出这八个步骤的流程图——不看书,凭记忆
  3. 找到 getLatestTokenCount()(第 293 行),理解"从后往前扫 /clear → 从前往后扫数据"的双向扫描
  4. 在第 521–533 行找到淘汰逻辑——用一个你自己编的例子验证:三个会话 A(老)、B(中)、C(新),什么样的时间关系下 B 会被淘汰?
  5. 找到第 578 行的 slice(0, 5)——思考:为什么这里不写成可配置的?把这个限制放在数据层(而非 UI 层)有什么优缺点?

13. 知识检查

问题 1

findActiveSessions() 在文件系统层就做 mtime 过滤,而不是读取所有文件后再过滤。这体现了什么设计原则?

问题 2

getLatestTokenCount() 为什么要从后往前扫描找 /clear?

问题 3

一个会话被判定为 wasCleared=true 的条件是什么?

问题 4

以下哪个场景下,旧会话不会被判定为"被新会话覆盖"?

问题 5

稳定编号(webapp, webapp-2, webapp-3)的排序依据是什么?

问题 6

用户点击隐藏了一个会话,之后该会话又有新活动了(用户又用起了那个项目)。下次刷新时会发生什么?

14. 推荐深入阅读

💡 提问提示:如果你对 JSONL 解析、淘汰逻辑、或者数据管线中的任何一个步骤有疑问,直接在这个对话中提问。下一课:refreshAllSessions:从数据到 UI