@leviyuan/lodestar 0.1.9 → 0.1.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -4,55 +4,67 @@
4
4
 
5
5
  # 夜航星 (Lodestar)
6
6
 
7
- **在你最熟悉的飞书群里,开一段不熄灯的 Claude Code 会话。**
7
+ **把 Claude Code 装进你的飞书群。一个群 = 一个项目 = 一段不熄灯的对话。**
8
8
 
9
- ## 项目哲学
9
+ 离开终端,但不离开 Claude Code。手机上、地铁里、半夜的床上,你只要拇指能点字,Claude 就在另一头跑着。
10
10
 
11
- AI 不是帮手,是倍率。它放大的不是体力,是你——你的直觉、判断和品味,每一样都被乘以一个你以前不敢想的系数。最终走多远,取决于被放大的你有多强。
11
+ ## 它为什么存在
12
12
 
13
- 夜航星让这件事真正发生:在你思考的地方接住想法,在你转身之后继续把它推向终点。一个群,一个项目,一段不熄灯的对话。你醒着它在听,你睡了它还在跑。
13
+ AI 不是帮手,是倍率。它放大的不是体力,是你——你的直觉、判断和品味,每一样都被乘以一个你以前不敢想的系数。最终走多远,取决于被放大的你有多强。
14
+
15
+ 夜航星让这件事真正发生:在你思考的地方接住想法,在你转身之后继续把它推向终点。**你醒着它在听,你睡了它还在跑。**
16
+
17
+ ## 你会得到什么
18
+
19
+ - 🌊 **真·流式卡片** — 飞书 Card Kit v1 streaming,Claude 一个 token 一个 token 地打在同一张卡片里,不是发一堆零碎消息刷屏。
20
+ - 🧠 **思考过程透明** — `thinking` 流式渲染,turn 结束后自动收起为可展开面板。每次工具调用也是一格折叠面板:折起是概述,展开看完整 input/output。
21
+ - 🔐 **权限审批就地完成** — 需要授权的工具调用,**原地**升级为 🔐 等审批状态,三颗按钮 `允许 / 始终允许 / 拒绝` 直接嵌在面板里。不弹独立卡片,不破坏时序。点完按钮,后续 output 接在同一条线上继续往下走。
22
+ - ❓ **结构化追问** — Claude 的 `AskUserQuestion` 在群里呈现为可点击选项行;不满意?直接在群里**打字回答**,daemon 会把自由文本当作 custom answer 发回去。多题串行,有进度计数和"已答 N 题"折叠历史。
23
+ - 📦 **状态面板一键唤出** — 发 `hi` 弹一张控制台:model、上下文占用 %、累计 tokens/cost、上一轮 delta、session id、订阅额度(5h / 7d 真实 utilization,直读 Anthropic 官方 OAuth Usage API,凭据走 `~/.claude/.credentials.json`,token 过期自动 refresh)、本机所有活跃项目并列展示。
24
+ - 📎 **图片 / 文件双向互传** — 用户在群里发图/文件,Claude 通过消息里的 `[file: /abs/path]` 提示就能读;Claude 在回复里写 `[[send: /abs/path]]`,标记被剥离,文件以独立消息发回群里。出站路径走 realpath + 白名单校验,只允许工作目录、`/tmp/lodestar-*`、inbox 三块,`/etc`、`~/.ssh`、`~/.config` 即使被符号链接绕也拒绝。
25
+ - 📲 **加急锁屏推送** — 需要你回答问题、需要你批准操作、一轮跑完了——三种关键时刻自动触发飞书"应用内加急",直接打穿勿扰、亮屏推送。卡片摘要会同步改写成具体待办("🔐 等审批: Bash · rm -rf …"、"❓ 待回答 3 题: …"),锁屏一瞥就知道发生了什么。
26
+ - 🗂 **多项目并发** — 一个 daemon 同时持有 N 个飞书群 ↔ N 个 Claude session。状态面板能跨群看到所有活跃项目和它们的 uptime,在群 A 里就能查群 B 在干嘛。
27
+ - 🔄 **不丢上下文** — 每次 `system/init` 落盘 SDK session_id;daemon 被 systemd 重启、机器断电、手抖 kill 进程,下次 `restart` 或自动复活都 `--resume` 到同一段对话,Claude 不知道你离开过。
28
+ - 🛡 **后台守护级稳定性** — 单 PID 锁、WS pong watchdog(180s 无心跳自杀,交给 systemd 拉起)、5s 重投 stale 消息丢弃、200 条 message_id 去重、SIGTERM 优雅写盘、`alive marker` 区分"我自己挂的"和"被用户主动 kill 的"——后者不会被复活。
14
29
 
15
30
  ## 怎么用
16
31
 
17
- 每个飞书群对应一个 Claude 会话。**群名 = `~/` 下的项目目录名**。
32
+ 每个飞书群对应一个 Claude 会话。**群名 = `~/` 下的项目目录名**。这套绑定是骨架,新群第一次发消息时,daemon 会自动 `mkdir -p ~/{群名}` + `git init` 把项目骨架打起来,**开新群 = 开新项目**。
18
33
 
19
- - 在群里发任意文字 — Claude 接管这一轮,回复以**流式打字机**实时渲染在一张飞书卡片里。
20
- - 思考过程、每一次工具调用都在卡片里被收纳为**可展开折叠面板**:折起来是概述,展开是详情。你随时能审阅它在做什么。
21
- - 需要授权的操作(执行命令、修改文件……)**就在原来那一格工具调用面板里**升级为 🔐 等审批状态,三颗按钮 `允许` / `始终允许` / `拒绝` 直接内嵌在面板里 — 不再弹独立卡片,决策结果与后续 output 串在同一条时序里。默认 `bypassPermissions`,所以这一格只在 SDK 真的拦下来时才出现。
22
- - **图片、文件双向互传**:用户发到群里的图/文件,Claude 通过消息里的 `[file: /abs/path]` 提示就能读;Claude 想把文件发回来,在回复任意位置写 `[[send: /abs/path]]`,标记会被剥离,文件以独立消息出现在群里。出站路径限制在该会话的工作目录、`/tmp/lodestar-*` 与 inbox 之内,`/etc`、`~/.ssh`、`~/.config` 等敏感目录被白名单拒绝。
23
- - 一轮跑完,卡片合上、可转发;下一句话开新一轮。
34
+ 在群里发任意文字,Claude 接管这一轮。回复以流式打字机渲染在一张卡片里,工具调用、思考过程、权限审批、追问选项,全都收纳在这张卡片的不同面板里——一目了然,可转发,可回看。
35
+
36
+ 下一句话开新一轮卡片。
24
37
 
25
38
  ### 文本控制指令
26
39
 
27
- 直接发这四个**裸词**(不需要斜杠,不区分大小写),daemon 拦截、不转发给 Claude
40
+ 直接发这四个**裸词**(不需要斜杠,不区分大小写),daemon 拦截、不转发给 Claude:
28
41
 
29
42
  | 指令 | 行为 |
30
43
  | --- | --- |
31
- | `hi` | 未运行时启动;运行中弹一张**状态卡片**(model · 上下文占用 · 累计 tokens/cost · 上一轮 delta · session id) |
32
- | `kill` | 优雅关闭 Claude 进程;记住 `sessionId`,下次 `restart` 还能 resume |
33
- | `restart` | 用上一次的 `sessionId` 重启会话(保留上下文) |
34
- | `clear` | 杀掉进程并启动一个全新 session(等价于 Claude Code 的 `/clear`) |
35
-
36
- > 这四个词被全局保留:在群里发 "hi" 当问候也会触发控制台卡片,不会到 Claude 那边。换来的是手机上单手打字的便利。
44
+ | `hi` | 未运行时启动;运行中弹一张**状态卡片** |
45
+ | `kill` | 优雅关闭 Claude 进程;记住 `sessionId`,下次 `restart` 还能 resume |
46
+ | `restart` | 用上一次的 `sessionId` 重启会话(保留上下文) |
47
+ | `clear` | 杀掉进程并启动一个全新 session(等价于 Claude Code 的 `/clear`) |
37
48
 
38
- 整个对话在群里、在手机上、在桌面上完整发生。**离开终端,但不离开 Claude Code。**
49
+ > 这四个词被全局保留:在群里发 "hi" 当问候也会触发控制台卡片,不会到 Claude 那边。换来的是手机上单手打字的便利。
39
50
 
40
51
  ## 安装
41
52
 
42
53
  ### 1. 准备
43
54
 
44
- - 一台能常跑后台进程的机器(自家服务器或闲置主机)
45
- - [Bun](https://bun.sh) 运行时
46
- - [Claude Code](https://docs.anthropic.com/en/docs/claude-code) 已登录 Anthropic 账号 (`claude auth login`)
47
- - 一个飞书自建应用 (`cli_xxx`),开通:
55
+ - 一台能常跑后台进程的机器(自家服务器、闲置 NAS、树莓派均可)
56
+ - [Bun](https://bun.sh) 运行时(≥ 1.0)
57
+ - [Claude Code](https://docs.anthropic.com/en/docs/claude-code) 装好且能跑(怎么认证、走官方账号还是第三方网关,你自己看着办)
58
+ - 一个飞书自建应用 (`cli_xxx`),开通:
48
59
  - `im:message:send_as_bot` / `im:message` / `im:chat:readonly` / `im:resource`
60
+ - `im:message.urgent`(加急推送)
49
61
  - `cardkit:card:read` `cardkit:card:write`
50
62
  `cardkit:card.element:read` `cardkit:card.element:write`
51
63
  `cardkit:card.settings:read` `cardkit:card.settings:write`
52
64
 
53
65
  ### 2. 配置
54
66
 
55
- 把凭据写到 `~/.config/lodestar/config.toml`:
67
+ 把凭据写到 `~/.config/lodestar/config.toml`:
56
68
 
57
69
  ```toml
58
70
  [feishu]
@@ -60,10 +72,10 @@ app_id = "cli_xxxxxxxxxxxxxxxx"
60
72
  app_secret = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
61
73
 
62
74
  [runtime]
63
- projects_root = "~/" # 可选,新建群对应的项目目录会落到这里
75
+ projects_root = "~/" # 可选,新建群对应的项目目录会落到这里
64
76
  ```
65
77
 
66
- 也支持 `LODESTAR_CONFIG=/abs/path.toml` 或 `XDG_CONFIG_HOME` 覆盖。
78
+ 也支持 `LODESTAR_CONFIG=/abs/path.toml` 或 `XDG_CONFIG_HOME` 覆盖。运行时状态走 `~/.local/share/lodestar/`(可用 `LODESTAR_DATA_DIR` 或 `XDG_DATA_HOME` 改写)——daemon.pid、daemon.log、session-chat-map、session-resume-map、alive-marker、inbox/ 都在那里。
67
79
 
68
80
  ### 3. 启动
69
81
 
@@ -72,19 +84,17 @@ bun install -g @leviyuan/lodestar
72
84
  lodestar-daemon
73
85
  ```
74
86
 
75
- 或者一次性跑(无需全局安装):
87
+ 或者一次性跑(无需全局安装):
76
88
 
77
89
  ```bash
78
90
  bunx @leviyuan/lodestar
79
91
  ```
80
92
 
81
- 把机器人拉进任意飞书群,发一条消息——Claude 就上线了。
93
+ 把机器人拉进任意飞书群,发一条消息——Claude 就上线了。
82
94
 
83
- > **小贴士**:群名首次出现时,daemon 会自动在 `~/{群名}/` 创建项目目录并 `git init`。换句话说,开新群 = 开新项目。
95
+ ### 4. 守护进程(推荐)
84
96
 
85
- ### 4. 守护进程(可选)
86
-
87
- 要让 daemon 7×24 跑,最简单的方法是配一个 `systemd --user` 单元:
97
+ daemon 7×24 跑,最简单的方法是配一个 `systemd --user` 单元:
88
98
 
89
99
  ```ini
90
100
  [Unit]
@@ -101,7 +111,11 @@ RestartSec=3
101
111
  WantedBy=default.target
102
112
  ```
103
113
 
104
- `systemctl --user enable --now lodestar`。
114
+ ```bash
115
+ systemctl --user enable --now lodestar
116
+ ```
117
+
118
+ WS watchdog + alive-marker 的联手设计,意味着每次 systemd 拉起,daemon 会把**上次还在运行的 session 全部 `--resume` 自动复活**;你主动 `kill` 过的不会被吵醒。
105
119
 
106
120
  ## 许可
107
121
 
package/daemon.ts CHANGED
@@ -175,7 +175,7 @@ async function handleMessage(data: any): Promise<void> {
175
175
  }
176
176
 
177
177
  if (!text && !filePath) return
178
- await session.onUserMessage(text || '(empty)', filePath ? [filePath] : [], userOpenId)
178
+ await session.onUserMessage(text || '(empty)', filePath ? [filePath] : [], userOpenId, msgId ?? '')
179
179
  }
180
180
 
181
181
  // ── Card action handler ────────────────────────────────────────────────
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leviyuan/lodestar",
3
- "version": "0.1.9",
3
+ "version": "0.1.11",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
package/src/cards.ts CHANGED
@@ -172,10 +172,18 @@ interface MainCardOpts {
172
172
  model?: string
173
173
  effort?: string
174
174
  userText: string
175
+ /** What started this turn. `'scheduled'` adds a top-of-card banner so
176
+ * the user can tell a cron-fired wakeup apart from one of their own
177
+ * messages — the user's message bubble is otherwise the only visual
178
+ * cue, and scheduled turns have no preceding bubble in the chat. */
179
+ kind?: 'user_message' | 'scheduled'
175
180
  }
176
181
 
177
182
  /** Initial card sent at the start of each turn. Streaming on. */
178
- export function mainConversationCard(_opts: MainCardOpts): object {
183
+ export function mainConversationCard(opts: MainCardOpts): object {
184
+ const banner = opts.kind === 'scheduled'
185
+ ? [{ tag: 'markdown', content: '⏰ **定时任务触发** — Claude 在 idle 间隙被 CronCreate / ScheduleWakeup 唤醒' }]
186
+ : []
179
187
  return {
180
188
  schema: '2.0',
181
189
  config: {
@@ -194,6 +202,7 @@ export function mainConversationCard(_opts: MainCardOpts): object {
194
202
  // thinking element starts with a single space placeholder; the first
195
203
  // real append overwrites it.
196
204
  elements: [
205
+ ...banner,
197
206
  { tag: 'markdown', element_id: ELEMENTS.thinking, content: ' ' },
198
207
  { tag: 'markdown', element_id: ELEMENTS.footer, content: '⏳ working…' },
199
208
  ],
@@ -593,48 +602,41 @@ const PEER_STATUS_EMOJI: Record<string, string> = {
593
602
 
594
603
  /** Render the subscription-usage section of the console card. Pulled out
595
604
  * of `consoleCard` so the caller can patch it in after the initial card
596
- * is on screen (ccusage's first cold call is ~5s; we'd rather not block
597
- * the whole panel on it). Layout intentionally splits 5h and 7d onto
598
- * their own indented lines for readability on phone.
605
+ * is on screen (网络往返可能慢于第一次 paint;先占位、回包后替换)。
599
606
  *
600
- * `usage === undefined` loading placeholder (initial paint).
601
- * `usage === null` → permanent "no data" (treat like installed but
602
- * empty; rare path).
603
- * `usage.installed=false` → install hint.
607
+ * 数据源是 Anthropic 官方 OAuth Usage API ( src/usage.ts)
608
+ * 百分比是真实 utilization,失败态按 state 区分显示具体原因。
609
+ *
610
+ * `usage === undefined` → 初始 loading 占位。
604
611
  */
605
612
  export function consoleUsageContent(
606
- usage: import('./usage').UsageSnapshot | null | undefined,
613
+ usage: import('./usage').UsageSnapshot | undefined,
607
614
  ): string {
608
615
  if (usage === undefined) return '**📊 订阅额度** _加载中…_'
609
- if (usage === null) return '**📊 订阅额度** _无数据_'
610
- if (!usage.installed) return '**📊 订阅额度** 未装 `ccusage` — `bun i -g ccusage`'
611
- // Format follows user spec: `5h X% $Y 剩Zh` / `7d X% $Y 剩Zd`.
612
- // Both % values are vs. the user's own historical peak (peak block
613
- // for 5h, peak week for 7d) since ccusage has no view into the
614
- // actual subscription tier cap. Omit chips that the data layer
615
- // couldn't supply rather than fabricate (no_fallbacks).
616
- const lines: string[] = ['**📊 订阅额度**']
616
+ switch (usage.state) {
617
+ case 'no_credentials':
618
+ return '**📊 订阅额度** 未找到 OAuth 凭据 (`~/.claude/.credentials.json`)'
619
+ case 'auth_failed':
620
+ return '**📊 订阅额度** Token 已过期且刷新失败 重新 `claude auth login`'
621
+ case 'rate_limited':
622
+ return '**📊 订阅额度** API 429 限流,稍后重试'
623
+ case 'network':
624
+ return `**📊 订阅额度** 拉取失败${usage.reason ? ' — `' + usage.reason + '`' : ''}`
625
+ }
626
+ // state === 'ok'
627
+ const head = usage.subscriptionType
628
+ ? `**📊 订阅额度** · ${usage.subscriptionType}`
629
+ : '**📊 订阅额度**'
630
+ const lines: string[] = [head]
617
631
  if (usage.fiveHour) {
618
- const parts: string[] = []
619
- if (usage.fiveHour.percentUsed != null) {
620
- parts.push(`${Math.round(usage.fiveHour.percentUsed)}%`)
621
- }
622
- parts.push(`$${Math.round(usage.fiveHour.costUsd)}`)
623
- if (usage.fiveHour.remainingMinutes != null) {
624
- parts.push(`剩${(usage.fiveHour.remainingMinutes / 60).toFixed(1)}h`)
625
- }
626
- lines.push(` · 5h ${parts.join(' ')}`)
632
+ const parts = [`${Math.round(usage.fiveHour.percent)}%`]
633
+ if (usage.fiveHour.resetsAt) parts.push(`重置 ${fmtResetIn(usage.fiveHour.resetsAt)}`)
634
+ lines.push(` · 5h ${parts.join(' · ')}`)
627
635
  }
628
636
  if (usage.weekly) {
629
- const parts: string[] = []
630
- if (usage.weekly.percentUsed != null) {
631
- parts.push(`${Math.round(usage.weekly.percentUsed)}%`)
632
- }
633
- parts.push(`$${Math.round(usage.weekly.costUsd)}`)
634
- if (usage.weekly.remainingDays != null) {
635
- parts.push(`剩${usage.weekly.remainingDays.toFixed(1)}d`)
636
- }
637
- lines.push(` · 7d ${parts.join(' ')}`)
637
+ const parts = [`${Math.round(usage.weekly.percent)}%`]
638
+ if (usage.weekly.resetsAt) parts.push(`重置 ${fmtResetIn(usage.weekly.resetsAt)}`)
639
+ lines.push(` · 7d ${parts.join(' · ')}`)
638
640
  }
639
641
  return lines.length === 1 ? '**📊 订阅额度** _无数据_' : lines.join('\n')
640
642
  }
package/src/feishu.ts CHANGED
@@ -194,14 +194,34 @@ export async function sendCard(chatId: string, card: object): Promise<string | n
194
194
  }
195
195
 
196
196
  // ── Reactions ──────────────────────────────────────────────────────────
197
- export async function addReaction(messageId: string, emojiType: string): Promise<void> {
198
- if (!messageId) return
197
+ /** Add an emoji reaction. Returns the new reaction_id on success (needed
198
+ * to delete the reaction later via {@link deleteReaction}) or null on
199
+ * failure. Failures are logged and swallowed — reactions are non-load-
200
+ * bearing UX, not worth bubbling errors. */
201
+ export async function addReaction(messageId: string, emojiType: string): Promise<string | null> {
202
+ if (!messageId) return null
199
203
  try {
200
- await client.im.messageReaction.create({
204
+ const res: any = await client.im.messageReaction.create({
201
205
  path: { message_id: messageId },
202
206
  data: { reaction_type: { emoji_type: emojiType } },
203
207
  })
204
- } catch (e) { log(`feishu: addReaction ${emojiType} on ${messageId} failed: ${e}`) }
208
+ return res?.data?.reaction_id ?? null
209
+ } catch (e) { log(`feishu: addReaction ${emojiType} on ${messageId} failed: ${e}`); return null }
210
+ }
211
+
212
+ /** Remove a previously-added reaction by its reaction_id (returned from
213
+ * {@link addReaction}). Used for the "queued → released" lifecycle: the
214
+ * OneSecond placed on arrival is *removed* when the daemon hands the
215
+ * message off to the SDK's batch / system-reminder pipeline, instead of
216
+ * stacking a second CheckMark on top — keeps the message's reaction row
217
+ * uncluttered. Quiet on failure. */
218
+ export async function deleteReaction(messageId: string, reactionId: string): Promise<void> {
219
+ if (!messageId || !reactionId) return
220
+ try {
221
+ await client.im.messageReaction.delete({
222
+ path: { message_id: messageId, reaction_id: reactionId },
223
+ })
224
+ } catch (e) { log(`feishu: deleteReaction ${reactionId} on ${messageId} failed: ${e}`) }
205
225
  }
206
226
 
207
227
  // ── Urgent push ───────────────────────────────────────────────────────
package/src/session.ts CHANGED
@@ -28,6 +28,12 @@ interface TurnState {
28
28
  * urgent_app push so only the initiator gets pinged (in case there
29
29
  * are other members in the group). Empty string → skip the ping. */
30
30
  userOpenId: string
31
+ /** What kicked off this turn. Only `'user_message'` turns fire the
32
+ * end-of-turn urgent_app push — scheduled / cron / loop wakeups
33
+ * finish on their own time and pinging the user would be noise,
34
+ * not signal. Ask / permission urgents inside the turn still fire
35
+ * regardless (those genuinely need attention even mid-schedule). */
36
+ trigger: 'user_message' | 'scheduled'
31
37
  userText: string
32
38
  thinkingText: string
33
39
  toolCount: number
@@ -92,6 +98,79 @@ export class Session {
92
98
 
93
99
  private proc: ClaudeProcess | null = null
94
100
  private currentTurn: TurnState | null = null
101
+ /** Count of user messages we've written to Claude's stdin since the last
102
+ * turn opened on our side. NOT a FIFO of individual messages — the SDK
103
+ * batch-merges every mid-turn user message into a single combined turn
104
+ * once the in-flight turn finishes, so the daemon only ever observes
105
+ * **one** init event per batch (no matter how many Feishu messages went
106
+ * into the batch). Tracking a count + last-sender (rather than an
107
+ * Array<msg>) keeps the daemon's view in sync with the SDK's actual
108
+ * dequeue semantics. Empirically verified 2026-05-15 from the SDK's
109
+ * `queue-operation` transcript events: 4 enqueues during a long turn
110
+ * → single dequeue at turn end → one merged user message. Count is
111
+ * decremented to 0 wholesale at the `init` boundary because the SDK
112
+ * has already collapsed them into one turn. Distinguishes user-msg
113
+ * turns from cron-fired scheduled wakeups: count > 0 ⇒ user;
114
+ * count === 0 ⇒ scheduled (and `initCount > 1`). */
115
+ private pendingUserMessageCount = 0
116
+ /** Most recent userOpenId seen via `onUserMessage`. Used only when a
117
+ * merged batch fires its init event and the daemon needs *some* open_id
118
+ * to scope the eventual `urgent_app` push — there's no obviously right
119
+ * answer when N messages from possibly different users collapse into
120
+ * one turn, and "the most recent sender" is a defensible default for
121
+ * the single-user private-bot scenario this product targets. */
122
+ private lastUserOpenId = ''
123
+ /** Feishu message_ids of user messages that arrived while the daemon
124
+ * was busy (turn in flight or mid-open), mapped to the `reaction_id`
125
+ * of the `OneSecond` reaction placed at arrival. The reaction_id is
126
+ * what `deleteReaction` needs to *remove* the OneSecond once the
127
+ * message has been absorbed by the SDK (either system-reminder
128
+ * injection mid-turn or a merged-batch dequeue on next turn).
129
+ * User feedback (2026-05-15): replacing OneSecond with a second
130
+ * CheckMark stacked two emojis on the same row; cleaner UX is
131
+ * "queued → released" via removal, not "queued → done" via
132
+ * stacking. */
133
+ private pendingReactionIds = new Map<string, string>()
134
+ /** Snapshot of `pendingReactionIds` taken when the init handler
135
+ * claims a merged batch — these are the Feishu messages whose
136
+ * OneSecond reactions are the currently-open turn's responsibility
137
+ * to clear (via deleteReaction). Empty for eager-opened solo turns
138
+ * and for scheduled wakeups (no user messages went into those). */
139
+ private currentBatchReactionIds = new Map<string, string>()
140
+ /** Set the moment a mid-turn user message lands. Tells the next
141
+ * content-adding event (assistant text delta or fresh tool_use) to
142
+ * rotate the card before applying its update — closes the in-flight
143
+ * card with a `📨 转交新卡` footer and opens a fresh card, so the
144
+ * continuation has a visible boundary instead of piling up under
145
+ * one card. Reset to false after the rotation fires (or on
146
+ * stop/restart/exit). User feedback (2026-05-15): the prior
147
+ * everything-in-one-card behavior made the order feel jumbled. */
148
+ private wantsRotation = false
149
+ /** Holds assistant / thinking / tool_use events that arrive while a
150
+ * card rotation is mid-flight (close-old → open-new straddles a
151
+ * Feishu API await window during which `currentTurn` is transiently
152
+ * null). Replayed onto the new card the moment rotation completes
153
+ * so no streamed token is lost across the boundary. */
154
+ private rotationBuffer: Array<
155
+ | { kind: 'assistant'; delta: string }
156
+ | { kind: 'thinking'; delta: string }
157
+ | { kind: 'tool_use'; id: string; name: string; input: any }
158
+ > = []
159
+ /** Count of `system/init` events seen this subprocess. The first one is
160
+ * the boot init (claimed by whichever user message lands first); all
161
+ * subsequent ones mark the start of an SDK-initiated turn (queued
162
+ * user message draining or a CronCreate fire). Reset on stop/restart/exit
163
+ * since `init` re-fires after every spawn. */
164
+ private initCount = 0
165
+ /** Sync guard set before any `await` in the eager-open path of
166
+ * `onUserMessage`, cleared after `currentTurn` is set. Closes the race
167
+ * where an SDK-emitted `init` event lands during the eager open's
168
+ * Feishu API await — without this, the init handler would observe
169
+ * `currentTurn === null && queue empty` (we've already shifted) and
170
+ * incorrectly open a *second* scheduled card for the same user
171
+ * message. The flag tells the init handler "an eager open is already
172
+ * claiming the slot, stand down". */
173
+ private openingTurn = false
95
174
  private pendingPermissions = new Map<string, { toolUseId: string }>()
96
175
  /** Open AskUserQuestion tool calls — keyed by tool_use_id. The SDK
97
176
  * routes AskUserQuestion through the can_use_tool flow even under
@@ -228,6 +307,14 @@ export class Session {
228
307
  this.lastSessionId = proc.sessionId ?? this.lastSessionId
229
308
  this.proc = null
230
309
  this.currentTurn = null
310
+ this.pendingUserMessageCount = 0
311
+ this.lastUserOpenId = ''
312
+ this.pendingReactionIds = new Map()
313
+ this.currentBatchReactionIds = new Map()
314
+ this.wantsRotation = false
315
+ this.rotationBuffer = []
316
+ this.initCount = 0
317
+ this.openingTurn = false
231
318
  this.pendingPermissions.clear()
232
319
  this.status = 'stopped'
233
320
  await proc.kill()
@@ -242,6 +329,14 @@ export class Session {
242
329
  this.proc = null
243
330
  }
244
331
  this.currentTurn = null
332
+ this.pendingUserMessageCount = 0
333
+ this.lastUserOpenId = ''
334
+ this.pendingReactionIds = new Map()
335
+ this.currentBatchReactionIds = new Map()
336
+ this.wantsRotation = false
337
+ this.rotationBuffer = []
338
+ this.initCount = 0
339
+ this.openingTurn = false
245
340
  this.pendingPermissions.clear()
246
341
  if (resume && prevSessionId) {
247
342
  this.proc = new ClaudeProcess({
@@ -271,15 +366,18 @@ export class Session {
271
366
  }
272
367
  }
273
368
 
274
- /** Run a bare-text control command (`hi`, `kill`, `restart`, `clear`).
369
+ /** Run a bare-text control command (`hi`, `stop`, `kill`, `restart`, `clear`).
275
370
  * Returns true if the command was consumed (don't forward to Claude).
276
371
  * Exact match, case-insensitive, ignores trailing whitespace.
277
372
  *
278
- * Trade-off (user-confirmed 2026-05-15): the four words are reserved
373
+ * Trade-off (user-confirmed 2026-05-15): these words are reserved
279
374
  * globally — typing "hi" as a literal greeting will show the console
280
375
  * card instead of reaching Claude. The ergonomic win (no slash, no
281
376
  * shift key, one-handed phone use) outweighs the collision in this
282
- * product's private-bot use case. */
377
+ * product's private-bot use case. `stop` was added 2026-05-15 once
378
+ * auto-interrupt on mid-turn user messages was removed (matching
379
+ * claude-code's native type-ahead behavior) — explicit barge-out
380
+ * needed a knob and `kill` (full subprocess teardown) is too heavy. */
283
381
  async runCommand(raw: string): Promise<boolean> {
284
382
  switch (raw.trim().toLowerCase()) {
285
383
  case 'hi':
@@ -289,6 +387,38 @@ export class Session {
289
387
  }
290
388
  await this.showConsole()
291
389
  return true
390
+ case 'stop':
391
+ // Soft barge-out: interrupt the current turn (if any) AND drop
392
+ // the pending-message count so a stack of type-ahead doesn't
393
+ // refire after the interrupt. Subprocess stays alive. Note: the
394
+ // SDK keeps its OWN internal queue of the user-text frames we
395
+ // already sendText'd — interrupt should also flush that side,
396
+ // but the daemon can't reach into it directly; in practice the
397
+ // sendInterrupt() control_request causes the SDK to discard
398
+ // queued input alongside the in-flight call.
399
+ if (!this.currentTurn && this.pendingUserMessageCount === 0) {
400
+ await feishu.sendText(this.chatId, '⚪ 当前没有正在执行的 turn')
401
+ return true
402
+ }
403
+ log(`session "${this.sessionName}": stop command — interrupt + drop count=${this.pendingUserMessageCount}`)
404
+ // Cancelled queued msgs: remove the OneSecond (no longer waiting)
405
+ // and stamp a CrossMark (explicit cancelled state, distinct from
406
+ // a natural release where reactions just disappear). Cancelled
407
+ // mid-batch msgs get the same treatment.
408
+ for (const [msgId, rid] of [
409
+ ...this.pendingReactionIds.entries(),
410
+ ...this.currentBatchReactionIds.entries(),
411
+ ]) {
412
+ if (rid) void feishu.deleteReaction(msgId, rid)
413
+ void feishu.addReaction(msgId, 'CrossMark')
414
+ }
415
+ this.pendingUserMessageCount = 0
416
+ this.lastUserOpenId = ''
417
+ this.pendingReactionIds = new Map()
418
+ this.currentBatchReactionIds = new Map()
419
+ this.wantsRotation = false
420
+ this.interrupt()
421
+ return true
292
422
  case 'kill':
293
423
  await this.stop()
294
424
  return true
@@ -359,19 +489,62 @@ export class Session {
359
489
  }
360
490
 
361
491
  // ── Inbound from Feishu ────────────────────────────────────────────
362
- async onUserMessage(text: string, files: string[] = [], userOpenId = ''): Promise<void> {
492
+ /** Inbound user message. Always writes to Claude's stdin immediately
493
+ * the SDK queues internally if a turn is in flight (FIFO, exactly the
494
+ * type-ahead semantics of the native claude-code REPL). Card opening:
495
+ * - First msg of session OR no turn in flight → open card eagerly here
496
+ * - Mid-flight msg → defer; the `init`
497
+ * handler opens its card when the SDK actually starts the turn
498
+ * This is what lets a single subprocess host both user-typed turns and
499
+ * cron-fired wakeups without the daemon ever calling `sendInterrupt` —
500
+ * `kill`/`stop` are the only paths that interrupt now. */
501
+ async onUserMessage(text: string, files: string[] = [], userOpenId = '', msgId = ''): Promise<void> {
363
502
  if (!this.isRunning()) {
364
503
  const ok = await this.start()
365
504
  if (!ok) return
366
505
  }
367
- if (this.currentTurn) {
368
- log(`session "${this.sessionName}": new turn arriving mid-flight, interrupting`)
369
- this.proc!.sendInterrupt()
370
- await this.closeTurnCard('🛑 用户打断')
371
- }
372
- await this.openTurnCard(text, userOpenId)
506
+ // Capture busy-state SYNC, before any state mutation — this decides
507
+ // whether the message will visibly queue (gets the OneSecond → later
508
+ // CheckMark lifecycle reactions on its Feishu chat message) or
509
+ // eager-open its own card (no reaction needed; the card itself is
510
+ // the acknowledgement).
511
+ const wasBusy = this.currentTurn !== null || this.openingTurn
512
+ this.pendingUserMessageCount++
513
+ this.lastUserOpenId = userOpenId
373
514
  this.proc!.sendUserText(text, files)
374
- this.status = 'working'
515
+ if (wasBusy && msgId) {
516
+ // Hold the slot in the map even if the API call hasn't returned
517
+ // yet — empty string is a sentinel meaning "we tried to react;
518
+ // reaction_id pending". When deleteReaction time comes, an empty
519
+ // string is a no-op (deleteReaction guards against it), which is
520
+ // the right behavior if the add failed.
521
+ this.pendingReactionIds.set(msgId, '')
522
+ void (async () => {
523
+ const rid = await feishu.addReaction(msgId, 'OneSecond')
524
+ if (rid && this.pendingReactionIds.has(msgId)) {
525
+ this.pendingReactionIds.set(msgId, rid)
526
+ }
527
+ })()
528
+ // Rotation hint: a mid-turn user msg means the next assistant /
529
+ // tool event should split the visual into a new card.
530
+ this.wantsRotation = true
531
+ }
532
+ if (!this.currentTurn && !this.openingTurn && this.initCount >= 1) {
533
+ // Eager open: this message is going to be processed solo (no current
534
+ // turn to merge with on the SDK side, so SDK runs it as its own turn).
535
+ // Claim one count and open the card with this message's own text +
536
+ // sender; any *additional* messages arriving during the open's
537
+ // Feishu API await will pile up in the count and get batched by the
538
+ // SDK into the NEXT turn (handled by the init handler).
539
+ this.openingTurn = true
540
+ this.pendingUserMessageCount--
541
+ try {
542
+ await this.openTurnCard(text, userOpenId, 'user_message')
543
+ this.status = 'working'
544
+ } finally {
545
+ this.openingTurn = false
546
+ }
547
+ }
375
548
  }
376
549
 
377
550
  async onPermissionDecision(
@@ -420,6 +593,15 @@ export class Session {
420
593
  return this.pendingAsks.size > 0
421
594
  }
422
595
 
596
+ /** True iff a turn is currently running (or a queued user message is
597
+ * waiting for its turn to start). daemon uses this to drop a hourglass
598
+ * reaction on inbound messages — without it the user sees no visible
599
+ * acknowledgement that their type-ahead message landed (the card
600
+ * doesn't open until the current turn finishes). */
601
+ isBusy(): boolean {
602
+ return this.currentTurn !== null || this.pendingUserMessageCount > 0
603
+ }
604
+
423
605
  /** Funnel an arbitrary chat message into the *current* question
424
606
  * of the oldest pending ask as a `customText` answer. Multi-
425
607
  * question semantics: from the user's perspective, the chat
@@ -575,6 +757,58 @@ export class Session {
575
757
  this.lastSessionId = p.sessionId
576
758
  feishu.bindSessionResume(this.sessionName, p.sessionId)
577
759
  }
760
+ this.initCount++
761
+
762
+ // The boot init (initCount === 1) only happens once per spawn and
763
+ // is claimed by whichever user message gets processed first — that
764
+ // message's card is opened eagerly in `onUserMessage`, so the boot
765
+ // init itself opens nothing. EXCEPTION: if a user message landed
766
+ // before the boot init (rare race during start()), the queue has
767
+ // an entry — drain it here.
768
+ //
769
+ // Subsequent inits (initCount >= 2) mark the start of an SDK-
770
+ // initiated turn — either the SDK draining its internal type-ahead
771
+ // queue (we'll have an entry in `pendingUserMessages` mirroring
772
+ // it) or a CronCreate / ScheduleWakeup fire (queue empty). The
773
+ // `currentTurn` / `openingTurn` checks guard the race where
774
+ // `onUserMessage` already eager-opened (or is mid-open) for the
775
+ // same user message and the SDK emitted an init#≥2 we don't need
776
+ // to act on. The init handler ALSO claims `openingTurn` for its
777
+ // own async open so a user message landing during the open
778
+ // doesn't spawn a duplicate card.
779
+ if (this.currentTurn || this.openingTurn) return
780
+ // `pendingUserMessageCount > 0` ⇒ SDK is about to fire an init for a
781
+ // merged batch of one-or-more user messages we already sendText'd
782
+ // (the eager-open path didn't claim them because a turn was still
783
+ // running at the time). Claim the ENTIRE count here — the SDK
784
+ // collapses them into ONE turn, so only one card opens; any further
785
+ // messages that arrive after this point will start a fresh count
786
+ // and a fresh batch.
787
+ const isUserBatch = this.pendingUserMessageCount > 0
788
+ const isScheduledFire = !isUserBatch && this.initCount > 1
789
+ if (!isUserBatch && !isScheduledFire) return
790
+ const userOpenId = isUserBatch ? this.lastUserOpenId : ''
791
+ if (isUserBatch) {
792
+ this.pendingUserMessageCount = 0
793
+ // Inherit the queued reaction_ids — this turn is collectively
794
+ // responsible for releasing their OneSecond reactions when it
795
+ // closes (via deleteReaction in closeTurnCard).
796
+ this.currentBatchReactionIds = this.pendingReactionIds
797
+ this.pendingReactionIds = new Map()
798
+ }
799
+ this.openingTurn = true
800
+ void (async () => {
801
+ try {
802
+ await this.openTurnCard(
803
+ isUserBatch ? '' : '⏰ 定时唤醒',
804
+ userOpenId,
805
+ isUserBatch ? 'user_message' : 'scheduled',
806
+ )
807
+ this.status = 'working'
808
+ } finally {
809
+ this.openingTurn = false
810
+ }
811
+ })()
578
812
  })
579
813
  p.on('assistant_text', ({ text }: { text: string }) => {
580
814
  this.appendAssistant(text)
@@ -604,6 +838,13 @@ export class Session {
604
838
  log(`session "${this.sessionName}": claude exited code=${code} signal=${signal} expected=${expected}`)
605
839
  this.proc = null
606
840
  this.currentTurn = null
841
+ this.pendingUserMessageCount = 0
842
+ this.lastUserOpenId = ''
843
+ this.pendingReactionIds = new Map()
844
+ this.currentBatchReactionIds = new Map()
845
+ this.wantsRotation = false
846
+ this.initCount = 0
847
+ this.openingTurn = false
607
848
  this.status = 'stopped'
608
849
  if (!expected && code !== 0 && signal !== 'SIGTERM') {
609
850
  void feishu.sendText(this.chatId, `⚠️ Claude 异常退出 (code=${code}, signal=${signal})。回复任意消息将重新启动。`)
@@ -642,13 +883,14 @@ export class Session {
642
883
  return this.lastTurnDelta?.inputTokens ?? 0
643
884
  }
644
885
 
645
- private async openTurnCard(userText: string, userOpenId: string): Promise<void> {
886
+ private async openTurnCard(userText: string, userOpenId: string, trigger: 'user_message' | 'scheduled'): Promise<void> {
646
887
  const turn = ++this.turnCounter
647
888
  const card = cards.mainConversationCard({
648
889
  sessionName: this.sessionName,
649
890
  turn,
650
891
  effort: 'max',
651
892
  userText,
893
+ kind: trigger,
652
894
  })
653
895
  const messageId = await feishu.sendCard(this.chatId, card)
654
896
  if (!messageId) { log(`session "${this.sessionName}": openTurnCard sendCard failed`); return }
@@ -659,6 +901,7 @@ export class Session {
659
901
  cardId,
660
902
  messageId,
661
903
  userOpenId,
904
+ trigger,
662
905
  userText,
663
906
  thinkingText: '',
664
907
  toolCount: 0,
@@ -676,8 +919,43 @@ export class Session {
676
919
  // forget here and rely on enqueue source order — that way no `await`
677
920
  // can yield mid-handler and let `closeTurnCard` (or another event) race
678
921
  // and mutate `this.currentTurn` underfoot.
922
+ /** Rotate to a fresh card mid-turn: close the in-flight card with a
923
+ * `📨 转交新卡` footer (distinct from `✅ done` and `🛑 打断`) and
924
+ * open a new card so the post-user-message continuation has a
925
+ * visible boundary. Streams that land during the rotation's await
926
+ * windows are buffered in `rotationBuffer` and replayed onto the
927
+ * new card the moment it's ready, so no tokens are lost across the
928
+ * cut. Caller guarantees `wantsRotation` was true sync-immediately
929
+ * before. */
930
+ private async rotateCard(): Promise<void> {
931
+ this.openingTurn = true
932
+ try {
933
+ await this.closeTurnCard('📨 转交新卡')
934
+ await this.openTurnCard('', this.lastUserOpenId, 'user_message')
935
+ } finally {
936
+ this.openingTurn = false
937
+ }
938
+ if (this.rotationBuffer.length === 0) return
939
+ const buf = this.rotationBuffer
940
+ this.rotationBuffer = []
941
+ for (const e of buf) {
942
+ if (e.kind === 'assistant') this.appendAssistant(e.delta)
943
+ else if (e.kind === 'thinking') this.appendThinking(e.delta)
944
+ else if (e.kind === 'tool_use') this.addTool(e.id, e.name, e.input)
945
+ }
946
+ }
947
+
679
948
  private appendAssistant(delta: string): void {
680
- if (!this.currentTurn) return
949
+ if (!this.currentTurn) {
950
+ if (this.openingTurn) this.rotationBuffer.push({ kind: 'assistant', delta })
951
+ return
952
+ }
953
+ if (this.wantsRotation) {
954
+ this.wantsRotation = false
955
+ this.rotationBuffer.push({ kind: 'assistant', delta })
956
+ void this.rotateCard()
957
+ return
958
+ }
681
959
  if (!this.currentTurn.currentAssistantSegmentId) {
682
960
  const i = this.currentTurn.assistantSegmentCount++
683
961
  const segId = cards.ELEMENTS.assistant(i)
@@ -703,7 +981,16 @@ export class Session {
703
981
  }
704
982
 
705
983
  private appendThinking(delta: string): void {
706
- if (!this.currentTurn) return
984
+ if (!this.currentTurn) {
985
+ if (this.openingTurn) this.rotationBuffer.push({ kind: 'thinking', delta })
986
+ return
987
+ }
988
+ if (this.wantsRotation) {
989
+ this.wantsRotation = false
990
+ this.rotationBuffer.push({ kind: 'thinking', delta })
991
+ void this.rotateCard()
992
+ return
993
+ }
707
994
  this.currentTurn.thinkingText += delta
708
995
  cardkit.streamTextThrottled(
709
996
  this.currentTurn.cardId,
@@ -721,7 +1008,16 @@ export class Session {
721
1008
  }
722
1009
 
723
1010
  private addTool(toolUseId: string, name: string, input: any): void {
724
- if (!this.currentTurn) return
1011
+ if (!this.currentTurn) {
1012
+ if (this.openingTurn) this.rotationBuffer.push({ kind: 'tool_use', id: toolUseId, name, input })
1013
+ return
1014
+ }
1015
+ if (this.wantsRotation) {
1016
+ this.wantsRotation = false
1017
+ this.rotationBuffer.push({ kind: 'tool_use', id: toolUseId, name, input })
1018
+ void this.rotateCard()
1019
+ return
1020
+ }
725
1021
  // Close current assistant segment (if any) so the tool panel renders
726
1022
  // AFTER it in card body order. Flush queues the segment's last
727
1023
  // buffered delta before the tool element is inserted.
@@ -1007,7 +1303,11 @@ export class Session {
1007
1303
  await cardkit.replaceElement(cardId, cards.ELEMENTS.thinking, cards.thinkingCollapsedPanel(thinkingText))
1008
1304
  }
1009
1305
  const sendNote = sendPaths.length ? ` · 📎 ${sendPaths.length}` : ''
1010
- const footer = `⏱ ${elapsed}s${suffix ? ' · ' + suffix : ''}${sendNote} · ✅ done`
1306
+ // Suffix REPLACES the trailing `✅ done` it represents a terminal
1307
+ // state distinct from natural completion (e.g. `📨 转交新卡` for a
1308
+ // mid-turn rotation). When absent, the turn ended cleanly.
1309
+ const stateMark = suffix ? ` · ${suffix}` : ' · ✅ done'
1310
+ const footer = `⏱ ${elapsed}s${sendNote}${stateMark}`
1011
1311
  await cardkit.streamText(cardId, cards.ELEMENTS.footer, footer)
1012
1312
  // Final chat-list preview: clean finish shows "⏱ Xs · NK tokens";
1013
1313
  // interrupted shows the suffix instead (no usage event landed).
@@ -1023,12 +1323,44 @@ export class Session {
1023
1323
 
1024
1324
  // Phone push on clean turn close so the user knows Claude is done
1025
1325
  // even with the chat backgrounded. Skip on interrupts (no real
1026
- // completion) and when we don't know who to ping. Fire-and-forget;
1027
- // urgent_app failures are non-fatal and already logged in feishu.ts.
1028
- if (!suffix && turn.userOpenId && turn.messageId) {
1326
+ // completion), when we don't know who to ping, and when the turn
1327
+ // wasn't kicked off by the user typing a message — scheduled /
1328
+ // cron / loop wakeups finish on their own and shouldn't ping the
1329
+ // phone. Fire-and-forget; urgent_app failures are non-fatal and
1330
+ // already logged in feishu.ts.
1331
+ if (!suffix && turn.trigger === 'user_message' && turn.userOpenId && turn.messageId) {
1029
1332
  void feishu.urgentApp(turn.messageId, [turn.userOpenId])
1030
1333
  }
1031
1334
 
1335
+ // Release the OneSecond reactions on every queued Feishu message
1336
+ // this turn was responsible for. Two buckets:
1337
+ // 1. `currentBatchReactionIds` — msgs the init handler explicitly
1338
+ // claimed (SDK dequeued them as a merged next-turn batch).
1339
+ // 2. `pendingReactionIds` — msgs whose fate is invisible to the
1340
+ // daemon: the SDK either dequeued them as part of the
1341
+ // JUST-CLOSED turn OR injected them mid-turn as
1342
+ // `<system-reminder>` and silently removed them from the
1343
+ // queue (common when the current turn had tool calls).
1344
+ // Without visibility into queue-operation events the daemon
1345
+ // can't tell which; the safe default is "the prior turn just
1346
+ // ended, so the msg is at least *acknowledged* now —
1347
+ // release the OneSecond and let it stop saying 'queued',
1348
+ // instead of leaving it stuck permanently."
1349
+ // For merged-batch follow-ups, this releases slightly early
1350
+ // (before the merged turn actually runs), which is an
1351
+ // acceptable trade vs. msgs stuck under OneSecond forever.
1352
+ const releaseEntries = [
1353
+ ...this.currentBatchReactionIds.entries(),
1354
+ ...this.pendingReactionIds.entries(),
1355
+ ]
1356
+ if (releaseEntries.length > 0) {
1357
+ for (const [msgId, rid] of releaseEntries) {
1358
+ if (rid) void feishu.deleteReaction(msgId, rid)
1359
+ }
1360
+ this.currentBatchReactionIds = new Map()
1361
+ this.pendingReactionIds = new Map()
1362
+ }
1363
+
1032
1364
  // Fire uploads sequentially AFTER the card is sealed so each file
1033
1365
  // posts as its own Feishu message below the conversation card.
1034
1366
  // Path gate: workDir (Claude's project sandbox), the inbox where
package/src/usage.ts CHANGED
@@ -1,201 +1,263 @@
1
1
  /**
2
2
  * Subscription usage snapshot for the `hi` console panel.
3
3
  *
4
- * Source: the `ccusage` CLI (https://github.com/ryoppippi/ccusage), which
5
- * parses Claude Code's local JSONL transcripts on demand. We shell out
6
- * twice in parallel and cache the merged result for CACHE_TTL_MS.
4
+ * Source: Anthropic 官方 OAuth Usage API —— `GET /api/oauth/usage`.
5
+ * 不再依赖外部 ccusage CLI。
7
6
  *
8
- * - `blocks --active --token-limit max` current 5h billing block.
9
- * `tokenLimitStatus.limit` is ccusage's "peak historical block"
10
- * value, used as the denominator for the 5h percentage. NOTE:
11
- * this is consumption relative to your own heaviest 5h ever —
12
- * NOT the Anthropic tier quota (which we have no way to read
13
- * without OAuth roundtrips). It's an internally-consistent burn
14
- * indicator, not an official quota gauge.
7
+ * 凭据来源: `~/.claude/.credentials.json`(Linux 服务器,无 macOS
8
+ * Keychain 分支)。结构由 Claude Code 写入,我们读 `claudeAiOauth`
9
+ * 字段拿 access_token / refresh_token / expires_at / subscriptionType /
10
+ * rateLimitTier。
15
11
  *
16
- * - `weekly --order desc` list of weekly aggregates, newest first.
17
- * ccusage's weekly doesn't expose tokenLimitStatus, so we compute
18
- * the same "peak historical week" ratio locally.
12
+ * access_token 过期时,用 refresh_token platform.claude.com
13
+ * `/v1/oauth/token` 刷新,刷新成功后原子写回凭据文件
14
+ * (tmp + rename),保证多进程并发安全。
19
15
  *
20
- * Failures stay visible (no fallback fabrication):
21
- * - ccusage not on PATH `installed: false` → card renders install hint.
22
- * - ccusage runs but yields nothing `fiveHour: null`, `weekly: null`.
16
+ * 失败可见 (no_fallbacks):
17
+ * - 凭据缺失 state='no_credentials'
18
+ * - 刷新也失败 state='auth_failed'
19
+ * - API 返回 429 → state='rate_limited' (+ resetsAt 可选)
20
+ * - 其它网络异常 → state='network'
21
+ *
22
+ * 卡片渲染层 (`cards.consoleUsageContent`) 按 state 分别显示具体原因,
23
+ * 不静默回退到旧值,不伪造百分比。
24
+ *
25
+ * Lodestar 启动后,每次 `hi` 弹板都会拉一次;CACHE_TTL_MS 内的重复
26
+ * 调用共享同一份快照,不打 API。in-flight 去重保证并发的多个
27
+ * 群同时唤出控制台时只触发一次后台请求。
28
+ *
29
+ * 参考实现: oh-my-claudecode HUD `src/hud/usage-api.ts`。这里只保留
30
+ * Lodestar 用得到的最小子集 —— 不处理 keychain、不处理第三方网关
31
+ * (z.ai / MiniMax)、不处理 enterprise 货币换算、不做多文件 cache 与
32
+ * 文件锁。
23
33
  */
24
34
 
25
- import { spawn } from 'node:child_process'
35
+ import { existsSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'
36
+ import { homedir } from 'node:os'
37
+ import { join } from 'node:path'
26
38
  import { log } from './log'
27
39
 
28
- const CCUSAGE_BIN = 'ccusage'
40
+ const USAGE_URL = 'https://api.anthropic.com/api/oauth/usage'
41
+ const TOKEN_REFRESH_URL = 'https://platform.claude.com/v1/oauth/token'
42
+ const OAUTH_CLIENT_ID = '9d1c250a-e61b-44d9-88ed-5944d1962f5e'
43
+ const API_TIMEOUT_MS = 10_000
29
44
  const CACHE_TTL_MS = 60_000
30
- const SPAWN_TIMEOUT_MS = 15_000
31
-
32
- export interface FiveHourBlock {
33
- costUsd: number
34
- totalTokens: number
35
- /** End of the current 5h billing window per ccusage. */
36
- windowEndsAt: Date
37
- /** Tokens/min over the current window, if ccusage reported one. */
38
- burnRatePerMin: number | null
39
- /** Consumption vs. user's historical peak 5h block (0–100). Null
40
- * when ccusage hasn't built a peak yet (very new install). */
41
- percentUsed: number | null
42
- /** Minutes left in this 5h window per ccusage's projection. */
43
- remainingMinutes: number | null
45
+
46
+ function credentialsPath(): string {
47
+ return join(homedir(), '.claude', '.credentials.json')
48
+ }
49
+
50
+ interface OAuthCredentials {
51
+ accessToken: string
52
+ refreshToken?: string
53
+ expiresAt?: number
54
+ subscriptionType?: string
55
+ rateLimitTier?: string
44
56
  }
45
57
 
46
- export interface WeeklyAggregate {
47
- /** ISO date of this week's start, format ccusage chose (Sun by default). */
48
- weekStart: string
49
- costUsd: number
50
- totalTokens: number
51
- /** Consumption vs. user's historical peak week (0–100). Null when
52
- * there's no prior week to compare against. */
53
- percentUsed: number | null
54
- /** Fractional days remaining until end of week (start + 7d). */
55
- remainingDays: number | null
58
+ export interface UsageWindow {
59
+ /** 0-100, Anthropic 直接返回的 utilization 真实值 */
60
+ percent: number
61
+ /** 这个窗口何时重置;ISO 解析失败则 null */
62
+ resetsAt: Date | null
56
63
  }
57
64
 
58
65
  export type UsageSnapshot =
59
- | { installed: false }
66
+ | { state: 'no_credentials' }
67
+ | { state: 'auth_failed' }
68
+ | { state: 'rate_limited' }
69
+ | { state: 'network'; reason?: string }
60
70
  | {
61
- installed: true
62
- fiveHour: FiveHourBlock | null
63
- weekly: WeeklyAggregate | null
64
- /** When this snapshot was computed. */
71
+ state: 'ok'
72
+ subscriptionType?: string
73
+ fiveHour: UsageWindow | null
74
+ weekly: UsageWindow | null
65
75
  fetchedAt: number
66
76
  }
67
77
 
68
- function clampPct(v: number): number {
69
- if (!isFinite(v)) return 0
70
- return Math.max(0, Math.min(100, v))
71
- }
72
-
73
78
  let cache: { data: UsageSnapshot; at: number } | null = null
74
79
  let inFlight: Promise<UsageSnapshot> | null = null
75
80
 
76
- /** `null` = not on PATH (ENOENT); `undefined` = ran but failed (timeout,
77
- * non-zero exit, JSON parse error). Distinct so the caller can render
78
- * different UX. */
79
- type RunResult = any | null | undefined
81
+ function readCredentials(): OAuthCredentials | null {
82
+ const path = credentialsPath()
83
+ if (!existsSync(path)) return null
84
+ try {
85
+ const raw = readFileSync(path, 'utf8')
86
+ const parsed = JSON.parse(raw)
87
+ const creds = parsed.claudeAiOauth ?? parsed
88
+ if (!creds?.accessToken) return null
89
+ return {
90
+ accessToken: creds.accessToken,
91
+ refreshToken: creds.refreshToken,
92
+ expiresAt: creds.expiresAt,
93
+ subscriptionType: creds.subscriptionType,
94
+ rateLimitTier: creds.rateLimitTier,
95
+ }
96
+ } catch (e) {
97
+ log(`usage: read credentials failed: ${e}`)
98
+ return null
99
+ }
100
+ }
80
101
 
81
- function runCcusage(args: string[]): Promise<RunResult> {
82
- return new Promise((resolve) => {
83
- let stdout = ''
84
- let stderr = ''
85
- let proc
102
+ /** 把刷新后的 access_token / refresh_token / expires_at 原子写回原文件,
103
+ * 保留其它字段(scopes、subscriptionType、organizationUuid 等)
104
+ * tmp + rename 防止半写状态被读到。 */
105
+ function writeBackCredentials(updated: OAuthCredentials): void {
106
+ const path = credentialsPath()
107
+ if (!existsSync(path)) return
108
+ try {
109
+ const parsed = JSON.parse(readFileSync(path, 'utf8'))
110
+ const target = parsed.claudeAiOauth ?? parsed
111
+ target.accessToken = updated.accessToken
112
+ if (updated.refreshToken) target.refreshToken = updated.refreshToken
113
+ if (updated.expiresAt != null) target.expiresAt = updated.expiresAt
114
+ const tmp = `${path}.tmp.${process.pid}`
86
115
  try {
87
- proc = spawn(CCUSAGE_BIN, args, { stdio: ['ignore', 'pipe', 'pipe'] })
88
- } catch (e: any) {
89
- if (e?.code === 'ENOENT') return resolve(null)
90
- log(`ccusage spawn threw: ${e}`)
91
- return resolve(undefined)
116
+ writeFileSync(tmp, JSON.stringify(parsed, null, 2), { mode: 0o600 })
117
+ renameSync(tmp, path)
118
+ } catch (e) {
119
+ try { if (existsSync(tmp)) unlinkSync(tmp) } catch {}
120
+ throw e
92
121
  }
122
+ } catch (e) {
123
+ log(`usage: writeBackCredentials failed: ${e}`)
124
+ }
125
+ }
93
126
 
94
- const timer = setTimeout(() => {
95
- proc.kill('SIGTERM')
96
- log(`ccusage ${args.join(' ')}: timeout after ${SPAWN_TIMEOUT_MS}ms`)
97
- }, SPAWN_TIMEOUT_MS)
127
+ function isExpired(creds: OAuthCredentials): boolean {
128
+ return creds.expiresAt != null && creds.expiresAt <= Date.now()
129
+ }
98
130
 
99
- proc.on('error', (err: any) => {
100
- clearTimeout(timer)
101
- if (err?.code === 'ENOENT') resolve(null)
102
- else { log(`ccusage error: ${err}`); resolve(undefined) }
131
+ async function refreshAccessToken(refreshToken: string): Promise<OAuthCredentials | null> {
132
+ const body = new URLSearchParams({
133
+ grant_type: 'refresh_token',
134
+ refresh_token: refreshToken,
135
+ client_id: OAUTH_CLIENT_ID,
136
+ }).toString()
137
+ const ctrl = new AbortController()
138
+ const timer = setTimeout(() => ctrl.abort(), API_TIMEOUT_MS)
139
+ try {
140
+ const res = await fetch(TOKEN_REFRESH_URL, {
141
+ method: 'POST',
142
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
143
+ body,
144
+ signal: ctrl.signal,
103
145
  })
104
- proc.stdout!.on('data', (b) => { stdout += b.toString() })
105
- proc.stderr!.on('data', (b) => { stderr += b.toString() })
106
- proc.on('close', (code) => {
107
- clearTimeout(timer)
108
- if (code !== 0) {
109
- log(`ccusage ${args.join(' ')}: exit ${code} stderr=${stderr.slice(0, 200)}`)
110
- return resolve(undefined)
111
- }
112
- try { resolve(JSON.parse(stdout)) }
113
- catch (e) { log(`ccusage JSON parse: ${e}`); resolve(undefined) }
114
- })
115
- })
146
+ if (res.status !== 200) {
147
+ log(`usage: token refresh HTTP ${res.status}`)
148
+ return null
149
+ }
150
+ const json = await res.json() as any
151
+ if (!json?.access_token) return null
152
+ return {
153
+ accessToken: json.access_token,
154
+ refreshToken: json.refresh_token ?? refreshToken,
155
+ expiresAt: json.expires_in
156
+ ? Date.now() + json.expires_in * 1000
157
+ : json.expires_at,
158
+ }
159
+ } catch (e) {
160
+ log(`usage: token refresh threw: ${e}`)
161
+ return null
162
+ } finally {
163
+ clearTimeout(timer)
164
+ }
116
165
  }
117
166
 
118
- async function fetchUsage(): Promise<UsageSnapshot> {
119
- const [blocks, weekly] = await Promise.all([
120
- // --active filters to the current 5h block (cheaper to parse).
121
- // --token-limit max derives a cap from the user's peak historical
122
- // block so ccusage emits `tokenLimitStatus`, giving us a numerator+
123
- // denominator without us reading every block ourselves.
124
- runCcusage(['blocks', '--json', '--active', '--token-limit', 'max']),
125
- runCcusage(['weekly', '--json', '--order', 'desc']),
126
- ])
127
-
128
- if (blocks === null || weekly === null) return { installed: false }
129
-
130
- let fiveHour: FiveHourBlock | null = null
131
- if (blocks && Array.isArray(blocks.blocks)) {
132
- const active = blocks.blocks.find((b: any) => b?.isActive && !b?.isGap)
133
- if (active) {
134
- const totalTokens = Number(active.totalTokens) || 0
135
- const limit = Number(active.tokenLimitStatus?.limit) || 0
136
- fiveHour = {
137
- costUsd: Number(active.costUSD) || 0,
138
- totalTokens,
139
- windowEndsAt: new Date(active.endTime),
140
- burnRatePerMin: typeof active.burnRate?.tokensPerMinute === 'number'
141
- ? active.burnRate.tokensPerMinute : null,
142
- percentUsed: limit > 0 ? clampPct((totalTokens / limit) * 100) : null,
143
- remainingMinutes: typeof active.projection?.remainingMinutes === 'number'
144
- ? active.projection.remainingMinutes : null,
145
- }
167
+ interface UsageApiResponse {
168
+ five_hour?: { utilization?: number; resets_at?: string }
169
+ seven_day?: { utilization?: number; resets_at?: string }
170
+ }
171
+
172
+ function parseDate(s: string | undefined): Date | null {
173
+ if (!s) return null
174
+ const d = new Date(s)
175
+ return isNaN(d.getTime()) ? null : d
176
+ }
177
+
178
+ function clampPct(v: number | undefined): number {
179
+ if (v == null || !isFinite(v)) return 0
180
+ return Math.max(0, Math.min(100, v))
181
+ }
182
+
183
+ interface FetchResult {
184
+ data: UsageApiResponse | null
185
+ /** 失败原因:undefined = 成功;其它字符串是分类错误。 */
186
+ reason?: 'rate_limited' | 'network'
187
+ detail?: string
188
+ }
189
+
190
+ async function fetchUsageFromApi(accessToken: string): Promise<FetchResult> {
191
+ const ctrl = new AbortController()
192
+ const timer = setTimeout(() => ctrl.abort(), API_TIMEOUT_MS)
193
+ try {
194
+ const res = await fetch(USAGE_URL, {
195
+ method: 'GET',
196
+ headers: {
197
+ Authorization: `Bearer ${accessToken}`,
198
+ 'anthropic-beta': 'oauth-2025-04-20',
199
+ 'Content-Type': 'application/json',
200
+ },
201
+ signal: ctrl.signal,
202
+ })
203
+ if (res.status === 200) {
204
+ const data = await res.json() as UsageApiResponse
205
+ return { data }
146
206
  }
207
+ if (res.status === 429) return { data: null, reason: 'rate_limited' }
208
+ return { data: null, reason: 'network', detail: `HTTP ${res.status}` }
209
+ } catch (e: any) {
210
+ return { data: null, reason: 'network', detail: e?.message ?? String(e) }
211
+ } finally {
212
+ clearTimeout(timer)
147
213
  }
214
+ }
148
215
 
149
- let wk: WeeklyAggregate | null = null
150
- if (weekly && Array.isArray(weekly.weekly) && weekly.weekly.length > 0) {
151
- const current = weekly.weekly[0]
152
- const totalTokens = Number(current.totalTokens) || 0
153
- // Peak historical week (excluding the current one — comparing
154
- // against itself would always read 100%). When this is the only
155
- // recorded week we leave percentUsed null.
156
- const peakTokens = weekly.weekly.slice(1).reduce(
157
- (m: number, w: any) => Math.max(m, Number(w?.totalTokens) || 0), 0)
158
- const percentUsed = peakTokens > 0 ? clampPct((totalTokens / peakTokens) * 100) : null
159
- // Week end = weekStart + 7 days. ccusage emits weekStart as YYYY-MM-DD;
160
- // parse as UTC so DST/timezone shifts don't drift the countdown.
161
- const weekStartIso = String(current.week ?? '')
162
- let remainingDays: number | null = null
163
- if (weekStartIso) {
164
- const start = new Date(weekStartIso + 'T00:00:00Z')
165
- if (!isNaN(start.getTime())) {
166
- const endMs = start.getTime() + 7 * 24 * 60 * 60 * 1000
167
- remainingDays = Math.max(0, (endMs - Date.now()) / (24 * 60 * 60 * 1000))
168
- }
169
- }
170
- wk = {
171
- weekStart: weekStartIso,
172
- costUsd: Number(current.totalCost) || 0,
173
- totalTokens,
174
- percentUsed,
175
- remainingDays,
176
- }
216
+ async function fetchUsage(): Promise<UsageSnapshot> {
217
+ let creds = readCredentials()
218
+ if (!creds) return { state: 'no_credentials' }
219
+
220
+ if (isExpired(creds)) {
221
+ if (!creds.refreshToken) return { state: 'auth_failed' }
222
+ const refreshed = await refreshAccessToken(creds.refreshToken)
223
+ if (!refreshed) return { state: 'auth_failed' }
224
+ creds = { ...creds, ...refreshed }
225
+ writeBackCredentials(creds)
177
226
  }
178
227
 
179
- return { installed: true, fiveHour, weekly: wk, fetchedAt: Date.now() }
228
+ const result = await fetchUsageFromApi(creds.accessToken)
229
+ if (result.reason === 'rate_limited') return { state: 'rate_limited' }
230
+ if (result.reason === 'network' || !result.data) return { state: 'network', reason: result.detail }
231
+
232
+ const data = result.data
233
+ const fiveHour = data.five_hour?.utilization != null
234
+ ? { percent: clampPct(data.five_hour.utilization), resetsAt: parseDate(data.five_hour.resets_at) }
235
+ : null
236
+ const weekly = data.seven_day?.utilization != null
237
+ ? { percent: clampPct(data.seven_day.utilization), resetsAt: parseDate(data.seven_day.resets_at) }
238
+ : null
239
+
240
+ return {
241
+ state: 'ok',
242
+ subscriptionType: creds.subscriptionType,
243
+ fiveHour,
244
+ weekly,
245
+ fetchedAt: Date.now(),
246
+ }
180
247
  }
181
248
 
182
- /** Returns a usage snapshot. Cached for CACHE_TTL_MS; concurrent callers
183
- * dedupe to a single in-flight ccusage run. First call after stale-out
184
- * pays the full ccusage cost (~5s on this machine); subsequent reads are
185
- * instant. Never throws — returns `{ installed: false }` if ccusage is
186
- * missing, or an empty `{ installed: true, fiveHour: null, ... }` if it
187
- * runs but yields no data. */
249
+ /** 返回订阅额度快照。CACHE_TTL_MS 内的重复调用读缓存;并发请求去重为
250
+ * 单次后台 fetch。永不抛出 —— 失败状态由 `state` 字段表达,卡片层
251
+ * state 分支渲染。 */
188
252
  export async function readUsage(): Promise<UsageSnapshot> {
189
253
  if (cache && Date.now() - cache.at < CACHE_TTL_MS) return cache.data
190
254
  if (inFlight) return inFlight
191
- inFlight = fetchUsage().then(d => {
192
- cache = { data: d, at: Date.now() }
193
- inFlight = null
194
- return d
195
- }).catch(e => {
196
- log(`usage: fetchUsage threw: ${e}`)
197
- inFlight = null
198
- return cache?.data ?? { installed: false }
199
- })
255
+ inFlight = fetchUsage()
256
+ .then(d => { cache = { data: d, at: Date.now() }; inFlight = null; return d })
257
+ .catch(e => {
258
+ log(`usage: fetchUsage threw: ${e}`)
259
+ inFlight = null
260
+ return cache?.data ?? { state: 'network', reason: String(e) }
261
+ })
200
262
  return inFlight
201
263
  }