前三课我们学会了扩展怎么说(package.json)、怎么活(生命周期)、怎么画(StatusBarItem)。这一课深入扩展的数据心脏——findActiveSessions()。它回答了扩展存在的最核心问题:"现在有哪些活跃的 Claude Code 会话?"
extension.ts 第 293–578 行。包含 getLatestTokenCount()、getContextLimitForModel()、findActiveSessions() 及其内部的稳定编号和手动隐藏逻辑。
文件系统 (~/.claude/projects/)
│
▼
findActiveSessions() ← 本课
│
▼
SessionInfo[] ← 结构化数据
│
▼
refreshAllSessions() ← Lesson 0003
│
▼
StatusBarItem[] ← 状态栏 UI
findActiveSessions() 是纯粹的数据函数——它不操作任何 UI,只做一件事:从文件系统中提取结构化数据。它返回的 SessionInfo[] 是 refreshAllSessions() 的唯一输入。
一次 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 条,防止状态栏被海量会话淹没
const claudeDir = getClaudeProjectsDir(); // → ~/.claude/projects/ const cutoffTime = Date.now() - (idleTimeout * 1000); // idleTimeout 默认 180 秒——只关心最近 3 分钟内有活动的会话
扫描逻辑遍历 ~/.claude/projects/ 下的每个子目录:
claude-plugins 和 claude-mem(后台进程,不是交互会话).jsonl 结尾的文件agent- 前缀的文件(后台 agent)mtime 在 cutoffTime 内的文件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() 的工作分两步:
C)→ Windows 路径;否则 → Unix 路径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 中的横线)。最终的项目名是近似值,而非精确路径重建。
对每个入选的 .jsonl 文件,调用 getLatestTokenCount()。这是整个管线中最耗时的步骤——它把整个文件读进内存并逐行解析 JSON。
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":"..."}} ... 更多行 ...
关键设计:解析器从文件最后一行往前扫,找最近一次 /clear 命令。判断一个会话是否"已清除"需要同时满足两个条件:
/clear 命令msgContent.includes('<command-name>/clear</command-name>'))
/clear 之后没有任何用户消息// 扫描逻辑:从最后一行往前扫 let lastClearIndex = -1; let userMessagesAfterClear = 0; for (i = lastLine → firstLine) { if (是 /clear) { lastClearIndex = i; break; } if (是用户消息) { userMessagesAfterClear++; } } // wasCleared = (找到了 /clear) && (clear 之后没有用户消息)
lastClearIndex 保持 -1。wasCleared 直接为 false,startIndex = 0,正向扫描从第一行开始,遍历整个文件收集元数据。不需要跳过任何东西。可以把 /clear 理解为文件中的一个书签——没书签就从封面读到封底,有书签就只读书签之后的部分。
确定 /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
一个会话的百分比 = 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 查询模型元信息——但都意味着额外的维护成本。
这是管线中逻辑最复杂的步骤。同一项目的会话被分组,组内按创建时间从新到老排序,然后逐一判断是否淘汰:
if (session.wasCleared) → continue; // 直接跳过
核心问题:同一个项目可以同时存在多个会话文件。过滤要回答的是——哪些会话还"活着",哪些已经被用户放弃了?
判断标准:存在一个更新的会话,其创建时间晚于该旧会话的最后更新时间。
以下用可视化时间线说明。假设 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 的含义。新会话出生时老会话已经不动了——说明用户开了新的,老的不要了。但如果新会话出生后老会话还有活动——说明用户在两个会话之间来回切换,两个都要保留。
过滤后,组内的活跃会话按创建时间从老到新排序。最老的会话保留原名,后续的加数字后缀:
活跃会话(按创建时间排序): sessionA (最老) → projectName = 'webapp' sessionB → projectName = 'webapp-2' sessionC (最新) → projectName = 'webapp-3'
编号是稳定的——基于 sessionCreated 时间戳,不会随刷新顺序变化。最老的永远是原始名。
用户点击状态栏条目时,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; // 仍然隐藏 }
这个逻辑确保了一个合理的行为:用户隐藏了一个会话,但如果它后来又有活动了(用户可能又用起了这个项目),就自动重新显示。如果用户只是点了一下隐藏但没再使用那个会话,它就一直不出现。
return visibleSessions.slice(0, 5);
最终返回最多 5 条。这是硬编码的——防止用户同时开了几十个会话把状态栏塞满。配合排序(按 lastUpdated 从新到老),这意味着最近活跃的 5 个会话会显示在状态栏中。
~/.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()
findActiveSessions()(第 409 行)getLatestTokenCount()(第 293 行),理解"从后往前扫 /clear → 从前往后扫数据"的双向扫描slice(0, 5)——思考:为什么这里不写成可配置的?把这个限制放在数据层(而非 UI 层)有什么优缺点?findActiveSessions() 在文件系统层就做 mtime 过滤,而不是读取所有文件后再过滤。这体现了什么设计原则?
getLatestTokenCount() 为什么要从后往前扫描找 /clear?
一个会话被判定为 wasCleared=true 的条件是什么?
以下哪个场景下,旧会话不会被判定为"被新会话覆盖"?
稳定编号(webapp, webapp-2, webapp-3)的排序依据是什么?
用户点击隐藏了一个会话,之后该会话又有新活动了(用户又用起了那个项目)。下次刷新时会发生什么?