@leviyuan/lodestar 0.1.10 → 0.1.12

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
@@ -16,10 +16,12 @@ AI 不是帮手,是倍率。它放大的不是体力,是你——你的直觉、
16
16
 
17
17
  ## 你会得到什么
18
18
 
19
- - 🌊 **真·流式卡片** — 飞书 Card Kit v1 streaming,Claude 一个 token 一个 token 地打在同一张卡片里,不是发一堆零碎消息刷屏。
19
+ - 🌊 **真·流式卡片** — 飞书 Card Kit v1 streaming,Claude 一个 token 一个 token 地打在同一张卡片里,不是发一堆零碎消息刷屏。每张 turn 卡片 footer 自带 `✅ ⏱ 12.3s · 📊 47% · 💰 $0.45`,本轮上下文占用 / 实付成本一眼可见。
20
20
  - 🧠 **思考过程透明** — `thinking` 流式渲染,turn 结束后自动收起为可展开面板。每次工具调用也是一格折叠面板:折起是概述,展开看完整 input/output。
21
21
  - 🔐 **权限审批就地完成** — 需要授权的工具调用,**原地**升级为 🔐 等审批状态,三颗按钮 `允许 / 始终允许 / 拒绝` 直接嵌在面板里。不弹独立卡片,不破坏时序。点完按钮,后续 output 接在同一条线上继续往下走。
22
22
  - ❓ **结构化追问** — Claude 的 `AskUserQuestion` 在群里呈现为可点击选项行;不满意?直接在群里**打字回答**,daemon 会把自由文本当作 custom answer 发回去。多题串行,有进度计数和"已答 N 题"折叠历史。
23
+ - ⌨️ **Type-ahead 不打断** — Claude 跑着你继续连珠炮,daemon 全部接住排队,排队消息打 `⏳` 反应,消化后清空(`stop` 取消则换 `❌`)。daemon 还会给每条合并消息前面注 `[#N]\n` 序号,模型一眼分得清"这是 5 条独立消息"而不是一个长字符串。turn 中途有新消息进来 + 下一个 tool_use 边界 → 旧卡 `📨 转交新卡` 收尾(既不是 done 也不是打断),新卡续写,边界跟语义对齐。
24
+ - ⏰ **定时唤醒可见化** — Claude 用 `CronCreate` / `ScheduleWakeup` 自己安排周期任务,到点子进程在 idle 间隙 fire,daemon 检测"非首次 init"自动开一张 `⏰ 定时唤醒` 卡片承接;这种自发 turn 不响加急(凌晨 3 点自检不该震你)。
23
25
  - 📦 **状态面板一键唤出** — 发 `hi` 弹一张控制台:model、上下文占用 %、累计 tokens/cost、上一轮 delta、session id、订阅额度(5h / 7d 真实 utilization,直读 Anthropic 官方 OAuth Usage API,凭据走 `~/.claude/.credentials.json`,token 过期自动 refresh)、本机所有活跃项目并列展示。
24
26
  - 📎 **图片 / 文件双向互传** — 用户在群里发图/文件,Claude 通过消息里的 `[file: /abs/path]` 提示就能读;Claude 在回复里写 `[[send: /abs/path]]`,标记被剥离,文件以独立消息发回群里。出站路径走 realpath + 白名单校验,只允许工作目录、`/tmp/lodestar-*`、inbox 三块,`/etc`、`~/.ssh`、`~/.config` 即使被符号链接绕也拒绝。
25
27
  - 📲 **加急锁屏推送** — 需要你回答问题、需要你批准操作、一轮跑完了——三种关键时刻自动触发飞书"应用内加急",直接打穿勿扰、亮屏推送。卡片摘要会同步改写成具体待办("🔐 等审批: Bash · rm -rf …"、"❓ 待回答 3 题: …"),锁屏一瞥就知道发生了什么。
@@ -42,11 +44,12 @@ AI 不是帮手,是倍率。它放大的不是体力,是你——你的直觉、
42
44
  | 指令 | 行为 |
43
45
  | --- | --- |
44
46
  | `hi` | 未运行时启动;运行中弹一张**状态卡片** |
47
+ | `stop` | 软打断当前 turn + 清空 type-ahead 排队;子进程保活,刚排队中的消息会被打 `CrossMark` 反应表示取消 |
45
48
  | `kill` | 优雅关闭 Claude 进程;记住 `sessionId`,下次 `restart` 还能 resume |
46
49
  | `restart` | 用上一次的 `sessionId` 重启会话(保留上下文) |
47
50
  | `clear` | 杀掉进程并启动一个全新 session(等价于 Claude Code 的 `/clear`) |
48
51
 
49
- > 这四个词被全局保留:在群里发 "hi" 当问候也会触发控制台卡片,不会到 Claude 那边。换来的是手机上单手打字的便利。
52
+ > 这五个词被全局保留:在群里发 "hi" 当问候也会触发控制台卡片,不会到 Claude 那边。换来的是手机上单手打字的便利。
50
53
 
51
54
  ## 安装
52
55
 
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.10",
3
+ "version": "0.1.12",
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
  ],
@@ -93,6 +93,12 @@ export class ClaudeProcess extends EventEmitter {
93
93
  lastResult: ClaudeResultMeta = {
94
94
  cost_usd: null, duration_ms: null, num_turns: null, usage: null,
95
95
  }
96
+ /** Context-window capacity of the model that ran the latest turn —
97
+ * lifted from `result.modelUsage[model].contextWindow` so we don't
98
+ * have to hardcode `[1m]` vs stock variants. 200K is the safe
99
+ * default if no result has landed yet (e.g. between spawn and the
100
+ * first turn close). */
101
+ lastContextWindow: number = 200_000
96
102
 
97
103
  constructor(opts: SpawnOpts) {
98
104
  super()
@@ -242,6 +248,19 @@ export class ClaudeProcess extends EventEmitter {
242
248
  num_turns: typeof msg.num_turns === 'number' ? msg.num_turns : null,
243
249
  usage: msg.usage ?? null,
244
250
  }
251
+ // modelUsage maps "<model id>" → { contextWindow, maxOutputTokens, … }.
252
+ // For mixed-model runs the SDK reports one entry per model used in
253
+ // the turn; we take the one matching `lastModel` (the assistant's
254
+ // latest model id) and fall back to any single entry if it's the
255
+ // only one — covers the common single-model case.
256
+ const mu = msg.modelUsage
257
+ if (mu && typeof mu === 'object') {
258
+ const entry = (this.lastModel && mu[this.lastModel])
259
+ || (Object.keys(mu).length === 1 ? mu[Object.keys(mu)[0]!] : null)
260
+ if (entry && typeof entry.contextWindow === 'number' && entry.contextWindow > 0) {
261
+ this.lastContextWindow = entry.contextWindow
262
+ }
263
+ }
245
264
  this.emit('result', msg)
246
265
  return
247
266
  }
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,70 @@ 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('🛑 用户打断')
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
514
+ // When this msg will be merged with siblings into a multi-content
515
+ // user turn (i.e. the SDK queued it because the daemon was busy),
516
+ // prepend a `[#N]\n` ordinal so the model can tell the merged
517
+ // blocks apart. Without it the harness renders multi-content text
518
+ // back-to-back ("1"+"2"+"5"+"56"+"89" → "1255689") and the model
519
+ // can't see the original boundaries — surfaced 2026-05-16 when a
520
+ // 5-msg accumulator test got mis-summed as one big number.
521
+ const wireText = wasBusy ? `[#${this.pendingUserMessageCount}]\n${text}` : text
522
+ this.proc!.sendUserText(wireText, files)
523
+ if (wasBusy && msgId) {
524
+ // Hold the slot in the map even if the API call hasn't returned
525
+ // yet — empty string is a sentinel meaning "we tried to react;
526
+ // reaction_id pending". When deleteReaction time comes, an empty
527
+ // string is a no-op (deleteReaction guards against it), which is
528
+ // the right behavior if the add failed.
529
+ this.pendingReactionIds.set(msgId, '')
530
+ void (async () => {
531
+ const rid = await feishu.addReaction(msgId, 'OneSecond')
532
+ if (rid && this.pendingReactionIds.has(msgId)) {
533
+ this.pendingReactionIds.set(msgId, rid)
534
+ }
535
+ })()
536
+ // Rotation hint: a mid-turn user msg means the next assistant /
537
+ // tool event should split the visual into a new card.
538
+ this.wantsRotation = true
539
+ }
540
+ if (!this.currentTurn && !this.openingTurn && this.initCount >= 1) {
541
+ // Eager open: this message is going to be processed solo (no current
542
+ // turn to merge with on the SDK side, so SDK runs it as its own turn).
543
+ // Claim one count and open the card with this message's own text +
544
+ // sender; any *additional* messages arriving during the open's
545
+ // Feishu API await will pile up in the count and get batched by the
546
+ // SDK into the NEXT turn (handled by the init handler).
547
+ this.openingTurn = true
548
+ this.pendingUserMessageCount--
549
+ try {
550
+ await this.openTurnCard(text, userOpenId, 'user_message')
551
+ this.status = 'working'
552
+ } finally {
553
+ this.openingTurn = false
554
+ }
371
555
  }
372
- await this.openTurnCard(text, userOpenId)
373
- this.proc!.sendUserText(text, files)
374
- this.status = 'working'
375
556
  }
376
557
 
377
558
  async onPermissionDecision(
@@ -420,6 +601,15 @@ export class Session {
420
601
  return this.pendingAsks.size > 0
421
602
  }
422
603
 
604
+ /** True iff a turn is currently running (or a queued user message is
605
+ * waiting for its turn to start). daemon uses this to drop a hourglass
606
+ * reaction on inbound messages — without it the user sees no visible
607
+ * acknowledgement that their type-ahead message landed (the card
608
+ * doesn't open until the current turn finishes). */
609
+ isBusy(): boolean {
610
+ return this.currentTurn !== null || this.pendingUserMessageCount > 0
611
+ }
612
+
423
613
  /** Funnel an arbitrary chat message into the *current* question
424
614
  * of the oldest pending ask as a `customText` answer. Multi-
425
615
  * question semantics: from the user's perspective, the chat
@@ -575,6 +765,58 @@ export class Session {
575
765
  this.lastSessionId = p.sessionId
576
766
  feishu.bindSessionResume(this.sessionName, p.sessionId)
577
767
  }
768
+ this.initCount++
769
+
770
+ // The boot init (initCount === 1) only happens once per spawn and
771
+ // is claimed by whichever user message gets processed first — that
772
+ // message's card is opened eagerly in `onUserMessage`, so the boot
773
+ // init itself opens nothing. EXCEPTION: if a user message landed
774
+ // before the boot init (rare race during start()), the queue has
775
+ // an entry — drain it here.
776
+ //
777
+ // Subsequent inits (initCount >= 2) mark the start of an SDK-
778
+ // initiated turn — either the SDK draining its internal type-ahead
779
+ // queue (we'll have an entry in `pendingUserMessages` mirroring
780
+ // it) or a CronCreate / ScheduleWakeup fire (queue empty). The
781
+ // `currentTurn` / `openingTurn` checks guard the race where
782
+ // `onUserMessage` already eager-opened (or is mid-open) for the
783
+ // same user message and the SDK emitted an init#≥2 we don't need
784
+ // to act on. The init handler ALSO claims `openingTurn` for its
785
+ // own async open so a user message landing during the open
786
+ // doesn't spawn a duplicate card.
787
+ if (this.currentTurn || this.openingTurn) return
788
+ // `pendingUserMessageCount > 0` ⇒ SDK is about to fire an init for a
789
+ // merged batch of one-or-more user messages we already sendText'd
790
+ // (the eager-open path didn't claim them because a turn was still
791
+ // running at the time). Claim the ENTIRE count here — the SDK
792
+ // collapses them into ONE turn, so only one card opens; any further
793
+ // messages that arrive after this point will start a fresh count
794
+ // and a fresh batch.
795
+ const isUserBatch = this.pendingUserMessageCount > 0
796
+ const isScheduledFire = !isUserBatch && this.initCount > 1
797
+ if (!isUserBatch && !isScheduledFire) return
798
+ const userOpenId = isUserBatch ? this.lastUserOpenId : ''
799
+ if (isUserBatch) {
800
+ this.pendingUserMessageCount = 0
801
+ // Inherit the queued reaction_ids — this turn is collectively
802
+ // responsible for releasing their OneSecond reactions when it
803
+ // closes (via deleteReaction in closeTurnCard).
804
+ this.currentBatchReactionIds = this.pendingReactionIds
805
+ this.pendingReactionIds = new Map()
806
+ }
807
+ this.openingTurn = true
808
+ void (async () => {
809
+ try {
810
+ await this.openTurnCard(
811
+ isUserBatch ? '' : '⏰ 定时唤醒',
812
+ userOpenId,
813
+ isUserBatch ? 'user_message' : 'scheduled',
814
+ )
815
+ this.status = 'working'
816
+ } finally {
817
+ this.openingTurn = false
818
+ }
819
+ })()
578
820
  })
579
821
  p.on('assistant_text', ({ text }: { text: string }) => {
580
822
  this.appendAssistant(text)
@@ -604,6 +846,13 @@ export class Session {
604
846
  log(`session "${this.sessionName}": claude exited code=${code} signal=${signal} expected=${expected}`)
605
847
  this.proc = null
606
848
  this.currentTurn = null
849
+ this.pendingUserMessageCount = 0
850
+ this.lastUserOpenId = ''
851
+ this.pendingReactionIds = new Map()
852
+ this.currentBatchReactionIds = new Map()
853
+ this.wantsRotation = false
854
+ this.initCount = 0
855
+ this.openingTurn = false
607
856
  this.status = 'stopped'
608
857
  if (!expected && code !== 0 && signal !== 'SIGTERM') {
609
858
  void feishu.sendText(this.chatId, `⚠️ Claude 异常退出 (code=${code}, signal=${signal})。回复任意消息将重新启动。`)
@@ -642,13 +891,24 @@ export class Session {
642
891
  return this.lastTurnDelta?.inputTokens ?? 0
643
892
  }
644
893
 
645
- private async openTurnCard(userText: string, userOpenId: string): Promise<void> {
894
+ /** Context-window capacity for the model the subprocess is currently
895
+ * running — sourced authoritatively from `result.modelUsage[model]
896
+ * .contextWindow` captured by ClaudeProcess on each turn close, so
897
+ * the daemon doesn't have to enumerate model ids itself (was the
898
+ * source of a "560K/200K" display bug — model id didn't include
899
+ * `[1m]` so the hardcoded fallback won). */
900
+ private contextWindowMax(): number {
901
+ return this.proc?.lastContextWindow ?? 200_000
902
+ }
903
+
904
+ private async openTurnCard(userText: string, userOpenId: string, trigger: 'user_message' | 'scheduled'): Promise<void> {
646
905
  const turn = ++this.turnCounter
647
906
  const card = cards.mainConversationCard({
648
907
  sessionName: this.sessionName,
649
908
  turn,
650
909
  effort: 'max',
651
910
  userText,
911
+ kind: trigger,
652
912
  })
653
913
  const messageId = await feishu.sendCard(this.chatId, card)
654
914
  if (!messageId) { log(`session "${this.sessionName}": openTurnCard sendCard failed`); return }
@@ -659,6 +919,7 @@ export class Session {
659
919
  cardId,
660
920
  messageId,
661
921
  userOpenId,
922
+ trigger,
662
923
  userText,
663
924
  thinkingText: '',
664
925
  toolCount: 0,
@@ -676,8 +937,44 @@ export class Session {
676
937
  // forget here and rely on enqueue source order — that way no `await`
677
938
  // can yield mid-handler and let `closeTurnCard` (or another event) race
678
939
  // and mutate `this.currentTurn` underfoot.
940
+ /** Rotate to a fresh card mid-turn: close the in-flight card with a
941
+ * `📨 转交新卡` footer (distinct from `✅ done` and `🛑 打断`) and
942
+ * open a new card so the post-user-message continuation has a
943
+ * visible boundary. Streams that land during the rotation's await
944
+ * windows are buffered in `rotationBuffer` and replayed onto the
945
+ * new card the moment it's ready, so no tokens are lost across the
946
+ * cut. Caller guarantees `wantsRotation` was true sync-immediately
947
+ * before. */
948
+ private async rotateCard(): Promise<void> {
949
+ this.openingTurn = true
950
+ try {
951
+ await this.closeTurnCard('📨 转交新卡')
952
+ await this.openTurnCard('', this.lastUserOpenId, 'user_message')
953
+ } finally {
954
+ this.openingTurn = false
955
+ }
956
+ if (this.rotationBuffer.length === 0) return
957
+ const buf = this.rotationBuffer
958
+ this.rotationBuffer = []
959
+ for (const e of buf) {
960
+ if (e.kind === 'assistant') this.appendAssistant(e.delta)
961
+ else if (e.kind === 'thinking') this.appendThinking(e.delta)
962
+ else if (e.kind === 'tool_use') this.addTool(e.id, e.name, e.input)
963
+ }
964
+ }
965
+
679
966
  private appendAssistant(delta: string): void {
680
- if (!this.currentTurn) return
967
+ if (!this.currentTurn) {
968
+ if (this.openingTurn) this.rotationBuffer.push({ kind: 'assistant', delta })
969
+ return
970
+ }
971
+ // Note: assistant text DOES NOT trigger rotation, even if a mid-turn
972
+ // user message landed and set `wantsRotation`. Rotating mid-segment
973
+ // would chop the model's in-progress reply (often a response to the
974
+ // ORIGINAL prompt that started this card) onto a fresh card,
975
+ // visually associating it with the queued msg — which is the bug
976
+ // the user surfaced 2026-05-16. The rotation defers to the next
977
+ // tool_use, which is a clean section boundary.
681
978
  if (!this.currentTurn.currentAssistantSegmentId) {
682
979
  const i = this.currentTurn.assistantSegmentCount++
683
980
  const segId = cards.ELEMENTS.assistant(i)
@@ -703,7 +1000,12 @@ export class Session {
703
1000
  }
704
1001
 
705
1002
  private appendThinking(delta: string): void {
706
- if (!this.currentTurn) return
1003
+ if (!this.currentTurn) {
1004
+ if (this.openingTurn) this.rotationBuffer.push({ kind: 'thinking', delta })
1005
+ return
1006
+ }
1007
+ // Thinking, like assistant text, doesn't trigger rotation — it's
1008
+ // preamble to the same response, not a section break.
707
1009
  this.currentTurn.thinkingText += delta
708
1010
  cardkit.streamTextThrottled(
709
1011
  this.currentTurn.cardId,
@@ -721,7 +1023,16 @@ export class Session {
721
1023
  }
722
1024
 
723
1025
  private addTool(toolUseId: string, name: string, input: any): void {
724
- if (!this.currentTurn) return
1026
+ if (!this.currentTurn) {
1027
+ if (this.openingTurn) this.rotationBuffer.push({ kind: 'tool_use', id: toolUseId, name, input })
1028
+ return
1029
+ }
1030
+ if (this.wantsRotation) {
1031
+ this.wantsRotation = false
1032
+ this.rotationBuffer.push({ kind: 'tool_use', id: toolUseId, name, input })
1033
+ void this.rotateCard()
1034
+ return
1035
+ }
725
1036
  // Close current assistant segment (if any) so the tool panel renders
726
1037
  // AFTER it in card body order. Flush queues the segment's last
727
1038
  // buffered delta before the tool element is inserted.
@@ -1007,7 +1318,28 @@ export class Session {
1007
1318
  await cardkit.replaceElement(cardId, cards.ELEMENTS.thinking, cards.thinkingCollapsedPanel(thinkingText))
1008
1319
  }
1009
1320
  const sendNote = sendPaths.length ? ` · 📎 ${sendPaths.length}` : ''
1010
- const footer = `⏱ ${elapsed}s${suffix ? ' · ' + suffix : ''}${sendNote} · ✅ done`
1321
+ // State marker leads the footer (✅ for natural completion, or the
1322
+ // suffix verbatim for non-natural states like `📨 转交新卡`). The
1323
+ // trailing "done" word is gone — the ✅ already carries that
1324
+ // meaning. User-confirmed footer order 2026-05-16.
1325
+ const stateMark = suffix ? suffix : '✅'
1326
+ // Per-turn metrics: context-window occupancy (as a real percentage,
1327
+ // not a token count) and dollar cost. Only meaningful on a clean
1328
+ // close — suffix-tagged turns (rotation / interrupt) didn't fire
1329
+ // the `result` event that populates `lastTurnDelta`, so these
1330
+ // numbers would be stale and misleading.
1331
+ let metrics = ''
1332
+ if (!suffix) {
1333
+ const ctxTokens = this.currentContextTokens()
1334
+ const ctxMax = this.contextWindowMax()
1335
+ if (ctxTokens > 0 && ctxMax > 0) {
1336
+ const pct = Math.round((ctxTokens / ctxMax) * 100)
1337
+ metrics += ` · 📊 ${pct}%`
1338
+ }
1339
+ const cost = this.lastTurnDelta?.costUsd ?? 0
1340
+ if (cost > 0) metrics += ` · 💰 $${cost.toFixed(3)}`
1341
+ }
1342
+ const footer = `${stateMark} ⏱ ${elapsed}s${metrics}${sendNote}`
1011
1343
  await cardkit.streamText(cardId, cards.ELEMENTS.footer, footer)
1012
1344
  // Final chat-list preview: clean finish shows "⏱ Xs · NK tokens";
1013
1345
  // interrupted shows the suffix instead (no usage event landed).
@@ -1023,12 +1355,44 @@ export class Session {
1023
1355
 
1024
1356
  // Phone push on clean turn close so the user knows Claude is done
1025
1357
  // 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) {
1358
+ // completion), when we don't know who to ping, and when the turn
1359
+ // wasn't kicked off by the user typing a message — scheduled /
1360
+ // cron / loop wakeups finish on their own and shouldn't ping the
1361
+ // phone. Fire-and-forget; urgent_app failures are non-fatal and
1362
+ // already logged in feishu.ts.
1363
+ if (!suffix && turn.trigger === 'user_message' && turn.userOpenId && turn.messageId) {
1029
1364
  void feishu.urgentApp(turn.messageId, [turn.userOpenId])
1030
1365
  }
1031
1366
 
1367
+ // Release the OneSecond reactions on every queued Feishu message
1368
+ // this turn was responsible for. Two buckets:
1369
+ // 1. `currentBatchReactionIds` — msgs the init handler explicitly
1370
+ // claimed (SDK dequeued them as a merged next-turn batch).
1371
+ // 2. `pendingReactionIds` — msgs whose fate is invisible to the
1372
+ // daemon: the SDK either dequeued them as part of the
1373
+ // JUST-CLOSED turn OR injected them mid-turn as
1374
+ // `<system-reminder>` and silently removed them from the
1375
+ // queue (common when the current turn had tool calls).
1376
+ // Without visibility into queue-operation events the daemon
1377
+ // can't tell which; the safe default is "the prior turn just
1378
+ // ended, so the msg is at least *acknowledged* now —
1379
+ // release the OneSecond and let it stop saying 'queued',
1380
+ // instead of leaving it stuck permanently."
1381
+ // For merged-batch follow-ups, this releases slightly early
1382
+ // (before the merged turn actually runs), which is an
1383
+ // acceptable trade vs. msgs stuck under OneSecond forever.
1384
+ const releaseEntries = [
1385
+ ...this.currentBatchReactionIds.entries(),
1386
+ ...this.pendingReactionIds.entries(),
1387
+ ]
1388
+ if (releaseEntries.length > 0) {
1389
+ for (const [msgId, rid] of releaseEntries) {
1390
+ if (rid) void feishu.deleteReaction(msgId, rid)
1391
+ }
1392
+ this.currentBatchReactionIds = new Map()
1393
+ this.pendingReactionIds = new Map()
1394
+ }
1395
+
1032
1396
  // Fire uploads sequentially AFTER the card is sealed so each file
1033
1397
  // posts as its own Feishu message below the conversation card.
1034
1398
  // Path gate: workDir (Claude's project sandbox), the inbox where