@leviyuan/lodestar 0.2.6 → 0.2.8

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leviyuan/lodestar",
3
- "version": "0.2.6",
3
+ "version": "0.2.8",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
package/src/cardkit.ts CHANGED
@@ -81,7 +81,12 @@ async function call(method: string, path: string, body?: object): Promise<any> {
81
81
  }
82
82
 
83
83
  function isStreamingClosed(e: unknown): boolean {
84
- return typeof e === 'object' && e !== null && (e as any).code === 300309
84
+ if (typeof e !== 'object' || e === null) return false
85
+ const code = (e as any).code
86
+ // 300309 "streaming mode is closed" — TTL already fired before our write.
87
+ // 200850 "card streaming timeout" — TTL fired exactly during our write.
88
+ // Both mean the streaming session is gone and a reopen will unstick the card.
89
+ return code === 300309 || code === 200850
85
90
  }
86
91
 
87
92
  /** Reopen streaming_mode on a card that Feishu auto-closed after its
@@ -98,15 +103,16 @@ async function reopenStreaming(cardId: string): Promise<void> {
98
103
  }
99
104
 
100
105
  /** Run `op` inside the per-card queue. If it fails with code=300309
101
- * (Feishu auto-closed streaming after the 10-minute TTL), reopen
102
- * streaming inline and retry `op` exactly once. Anything else — non-
103
- * 300309 failure, reopen failure, retry failure — is logged and
104
- * swallowed, matching the fire-and-forget contract every cardkit op
105
- * already has at the call sites. */
106
+ * or 200850 (Feishu auto-closed / timed-out streaming after the 10-
107
+ * minute TTL), reopen streaming inline and retry `op` exactly once.
108
+ * Anything else — other failure, reopen failure, retry failure — is
109
+ * logged and swallowed, matching the fire-and-forget contract every
110
+ * cardkit op already has at the call sites. */
106
111
  async function withReopenOnStreamingClosed(
107
112
  cardId: string,
108
113
  label: string,
109
114
  op: () => Promise<void>,
115
+ onFailure?: () => void,
110
116
  ): Promise<void> {
111
117
  try {
112
118
  await op()
@@ -114,20 +120,23 @@ async function withReopenOnStreamingClosed(
114
120
  } catch (e) {
115
121
  if (!isStreamingClosed(e)) {
116
122
  log(`cardkit ${label} ${cardId}: ${e}`)
123
+ if (onFailure) onFailure()
117
124
  return
118
125
  }
119
- log(`cardkit ${label} ${cardId}: streaming closed (300309) — reopening`)
126
+ log(`cardkit ${label} ${cardId}: streaming closed (code=${(e as any).code}) — reopening`)
120
127
  }
121
128
  try {
122
129
  await reopenStreaming(cardId)
123
130
  } catch (re) {
124
131
  log(`cardkit STREAMING_REOPEN_FAILED ${cardId}: ${re}`)
132
+ if (onFailure) onFailure()
125
133
  return
126
134
  }
127
135
  try {
128
136
  await op()
129
137
  } catch (e2) {
130
138
  log(`cardkit ${label} ${cardId} retry-after-reopen: ${e2}`)
139
+ if (onFailure) onFailure()
131
140
  }
132
141
  }
133
142
 
@@ -201,11 +210,20 @@ export async function flush(cardId: string): Promise<void> {
201
210
  }
202
211
  }
203
212
 
204
- /** Add a new element to the card body or relative to a sibling. */
213
+ /** Add a new element to the card body or relative to a sibling.
214
+ *
215
+ * `onFailure` fires asynchronously (after promise queue settles) if the
216
+ * element was NOT created — either the first attempt failed with a non-
217
+ * 300309 error, or the retry-after-reopen also failed. Use it to invalidate
218
+ * any daemon-side reference to the element you tried to add (e.g. a segment
219
+ * id), so subsequent writes don't keep PUTting content to a phantom element
220
+ * that Feishu will silently reject. Default (no callback) preserves the
221
+ * legacy fire-and-forget swallow behavior. */
205
222
  export function addElement(
206
223
  cardId: string,
207
224
  element: object,
208
225
  opts: { type?: 'append' | 'insert_before' | 'insert_after'; targetElementId?: string } = {},
226
+ onFailure?: () => void,
209
227
  ): Promise<void> {
210
228
  const s = state(cardId)
211
229
  s.queue = s.queue.then(() => withReopenOnStreamingClosed(
@@ -220,6 +238,7 @@ export function addElement(
220
238
  sequence: seq,
221
239
  })
222
240
  },
241
+ onFailure,
223
242
  ))
224
243
  return s.queue
225
244
  }
package/src/session.ts CHANGED
@@ -91,11 +91,14 @@ interface LastTurnDelta {
91
91
  inputTokens: number // input + cache_* (excludes output) — context-window estimate
92
92
  }
93
93
 
94
- /** Cumulative session counters. Reset on full restart (`clear`), preserved
95
- * across resume but resumed conversations start counting from the
96
- * resume point, not the original turn 0; the SDK doesn't replay historical
97
- * usage. The session_id continuity is preserved separately by the resume
98
- * map; cumStats represents "since the current ClaudeProcess was spawned". */
94
+ /** Cumulative session counters. Reset on full restart (`clear`),
95
+ * preserved across `restart`/resume and daemon-restart so the `hi`
96
+ * panel reflects the user's total spend in this conversation
97
+ * regardless of how many times the underlying ClaudeProcess has been
98
+ * respawned. Resumed conversations start counting from the resume
99
+ * point onward — the SDK doesn't replay historical usage on resume,
100
+ * so a long pre-resume conversation shows up as zero here until the
101
+ * first new turn lands. */
99
102
  interface CumStats {
100
103
  tokens: number
101
104
  costUsd: number
@@ -115,18 +118,25 @@ export class Session {
115
118
  private currentTurn: TurnState | null = null
116
119
  /** Count of user messages we've written to Claude's stdin since the last
117
120
  * turn opened on our side. NOT a FIFO of individual messages — the SDK
118
- * batch-merges every mid-turn user message into a single combined turn
119
- * once the in-flight turn finishes, so the daemon only ever observes
120
- * **one** init event per batch (no matter how many Feishu messages went
121
- * into the batch). Tracking a count + last-sender (rather than an
122
- * Array<msg>) keeps the daemon's view in sync with the SDK's actual
123
- * dequeue semantics. Empirically verified 2026-05-15 from the SDK's
124
- * `queue-operation` transcript events: 4 enqueues during a long turn
125
- * single dequeue at turn end one merged user message. Count is
126
- * decremented to 0 wholesale at the `init` boundary because the SDK
127
- * has already collapsed them into one turn. Distinguishes user-msg
128
- * turns from cron-fired scheduled wakeups: count > 0 user;
129
- * count === 0 scheduled (and `initCount > 1`). */
121
+ * USUALLY batch-merges every mid-turn user message into a single
122
+ * combined turn once the in-flight turn finishes, so most of the time
123
+ * the daemon observes **one** init event per batch. Tracking a count +
124
+ * last-sender (rather than an Array<msg>) keeps the daemon's view
125
+ * loosely in sync with the SDK's dequeue semantics. Caveat verified
126
+ * 2026-05-17 (test1 accumulator, 8-message rapid-fire): when the first
127
+ * write lands in an idle SDK, that single msg gets its own turn and
128
+ * the rest merge into a second turn i.e. 1+(N-1) split, not always
129
+ * one merge. To stay coherent with this, `drainMidTurnAndOpen` bumps
130
+ * the count by `batch.length` up front (covering both the first solo
131
+ * turn and the eventual merged tail), and the init handler resets to
132
+ * 0 on the first claim. If the SDK takes the merge path, the bail at
133
+ * `currentTurn=yes` in the init handler leaves pendingCount stale (>0)
134
+ * until the GC at the next `onUserMessage`; if it takes the split
135
+ * path, the second init sees pendingCount>0 and correctly classifies
136
+ * the trailing batch as user-batch (not a scheduled wakeup).
137
+ * Distinguishes user-msg turns from cron-fired scheduled
138
+ * wakeups: count > 0 ⇒ user; count === 0 ⇒ scheduled (and
139
+ * `initCount > 1`). */
130
140
  private pendingUserMessageCount = 0
131
141
  /** Mid-turn user messages buffered DAEMON-SIDE (not yet sendUserText'd
132
142
  * to the SDK). Drained in the `result` handler by writing each to SDK
@@ -294,6 +304,27 @@ export class Session {
294
304
  return true
295
305
  }
296
306
 
307
+ /** Drop every ⏳ OneSecond reaction this session is currently holding
308
+ * on user chat messages, then empty the two tracking maps. Used by
309
+ * every tear-down path (proc exit, kill, restart) so reactions don't
310
+ * outlive the conversation that placed them — without this, a Claude
311
+ * crash / daemon SIGTERM leaves orphan ⏳ stuck on user messages until
312
+ * Feishu's UI eventually GCs them (which it doesn't, in practice).
313
+ * closeTurnCard has its own release pass (with the slightly-early
314
+ * merged-batch trade-off documented there); this is the catastrophic-
315
+ * exit pass. Direct `deleteReaction` calls are fire-and-forget and
316
+ * swallow their own failures (see feishu.deleteReaction). */
317
+ private releaseAllReactions(): void {
318
+ for (const [msgId, rid] of [
319
+ ...this.pendingReactionIds.entries(),
320
+ ...this.currentBatchReactionIds.entries(),
321
+ ]) {
322
+ if (rid) void feishu.deleteReaction(msgId, rid)
323
+ }
324
+ this.pendingReactionIds = new Map()
325
+ this.currentBatchReactionIds = new Map()
326
+ }
327
+
297
328
  async stop(reason = '已终止'): Promise<void> {
298
329
  if (!this.proc) {
299
330
  this.status = 'stopped'
@@ -315,8 +346,7 @@ export class Session {
315
346
  this.pendingUserMessageCount = 0
316
347
  this.pendingMidTurnMsgs = []
317
348
  this.lastUserOpenId = ''
318
- this.pendingReactionIds = new Map()
319
- this.currentBatchReactionIds = new Map()
349
+ this.releaseAllReactions()
320
350
  this.initCount = 0
321
351
  this.openingTurn = false
322
352
  this.pendingPermissions.clear()
@@ -336,8 +366,7 @@ export class Session {
336
366
  this.pendingUserMessageCount = 0
337
367
  this.pendingMidTurnMsgs = []
338
368
  this.lastUserOpenId = ''
339
- this.pendingReactionIds = new Map()
340
- this.currentBatchReactionIds = new Map()
369
+ this.releaseAllReactions()
341
370
  this.initCount = 0
342
371
  this.openingTurn = false
343
372
  this.pendingPermissions.clear()
@@ -619,25 +648,37 @@ export class Session {
619
648
  // - first message after spawn (init not yet fired)
620
649
  // - bootstrap race (sibling msgs landing before init#1)
621
650
  // - solo message after a prior turn has fully closed
622
- this.proc!.sendUserText(wireText, files)
623
- this.pendingUserMessageCount++
624
- if (wasBusy && msgId) {
625
- // Bootstrap race: the init handler will open the card for us; until
626
- // then the OneSecond is the only ack the user gets.
627
- trackReaction(msgId)
628
- }
651
+ // Eager-open path: open the card BEFORE feeding SDK, so a card-open
652
+ // failure doesn't strand the daemon with SDK processing a turn we
653
+ // have nowhere to render. `!openingTurn` means no sibling is mid-
654
+ // open; `initCount >= 1` means SDK boot init has fired (otherwise
655
+ // the init handler owns turn opening and we just feed the queue
656
+ // below). On failure openTurnCard surfaces a red banner via
657
+ // sendTextRaw; SDK was idle so no interrupt needed.
629
658
  if (!this.openingTurn && this.initCount >= 1) {
630
- // Eager open: SDK is healthy and idle, open card now. Any extra
631
- // messages arriving during the open's Feishu API await pile up in
632
- // the count and the init handler batches them.
633
659
  this.openingTurn = true
634
- this.pendingUserMessageCount--
635
660
  try {
636
661
  await this.openTurnCard(userOpenId, 'user_message')
662
+ if (!this.currentTurn) return
663
+ this.proc!.sendUserText(wireText, files)
664
+ this.pendingUserMessageCount++
637
665
  this.status = 'working'
638
666
  } finally {
639
667
  this.openingTurn = false
640
668
  }
669
+ return
670
+ }
671
+
672
+ // Non-eager path: either init hasn't fired yet (cold start) or a
673
+ // sibling onUserMessage is already opening. Feed SDK directly; the
674
+ // init handler / sibling card-opener will batch this message in.
675
+ this.proc!.sendUserText(wireText, files)
676
+ this.pendingUserMessageCount++
677
+ if (wasBusy && msgId) {
678
+ // Bootstrap race / sibling-opening race: until a card is open,
679
+ // the OneSecond ⏳ is the only ack the user gets. The init handler
680
+ // inherits these via currentBatchReactionIds when it opens.
681
+ trackReaction(msgId)
641
682
  }
642
683
  }
643
684
 
@@ -873,7 +914,19 @@ export class Session {
873
914
  void (async () => {
874
915
  try {
875
916
  await this.openTurnCard(userOpenId, isUserBatch ? 'user_message' : 'scheduled')
876
- this.status = 'working'
917
+ if (!this.currentTurn) {
918
+ // SDK already started this turn (its `init` is what got us
919
+ // here) but we have no card to render into. Interrupt so
920
+ // assistant/tool events aren't silently dropped while the
921
+ // model burns tokens. Release the reactions this batch
922
+ // inherited (init handler moved them above) — otherwise
923
+ // they stay ⏳ forever on the user's chat messages.
924
+ log(`session "${this.sessionName}": init-path openTurnCard failed — sendInterrupt + release reactions`)
925
+ this.proc?.sendInterrupt()
926
+ this.releaseAllReactions()
927
+ } else {
928
+ this.status = 'working'
929
+ }
877
930
  } finally {
878
931
  this.openingTurn = false
879
932
  }
@@ -923,8 +976,7 @@ export class Session {
923
976
  this.pendingUserMessageCount = 0
924
977
  this.pendingMidTurnMsgs = []
925
978
  this.lastUserOpenId = ''
926
- this.pendingReactionIds = new Map()
927
- this.currentBatchReactionIds = new Map()
979
+ this.releaseAllReactions()
928
980
  this.initCount = 0
929
981
  this.openingTurn = false
930
982
  this.status = 'stopped'
@@ -984,7 +1036,17 @@ export class Session {
984
1036
  * calls wake the SDK polling loop (priority="now" semantics) and
985
1037
  * comprise the input for the new turn. Opens the card here rather
986
1038
  * than deferring to init because the init for this batch will arrive
987
- * with `currentTurn` already set and bail. */
1039
+ * with `currentTurn` already set and bail.
1040
+ *
1041
+ * Each sendUserText also bumps `pendingUserMessageCount`. The SDK
1042
+ * USUALLY collapses our N writes into one merged turn, but **not
1043
+ * always** — empirically observed 2026-05-17, test1 accumulator
1044
+ * session: when the first write lands in an idle SDK (turn just
1045
+ * ended), the SDK eagerly starts a turn for that msg alone, then
1046
+ * merges the rest into a second turn. Without the bump here, that
1047
+ * second turn fires an `init` with `pendingUserMessageCount === 0`
1048
+ * and the init handler misclassifies it as a scheduled wakeup,
1049
+ * painting the `⏰ 触发` banner on what is really a user batch. */
988
1050
  private async drainMidTurnAndOpen(): Promise<void> {
989
1051
  if (this.pendingMidTurnMsgs.length === 0) return
990
1052
  const batch = this.pendingMidTurnMsgs
@@ -993,6 +1055,7 @@ export class Session {
993
1055
  try {
994
1056
  for (const msg of batch) {
995
1057
  this.proc!.sendUserText(msg.wireText, msg.files)
1058
+ this.pendingUserMessageCount++
996
1059
  if (msg.msgId) {
997
1060
  const rid = this.pendingReactionIds.get(msg.msgId) ?? ''
998
1061
  this.currentBatchReactionIds.set(msg.msgId, rid)
@@ -1031,11 +1094,10 @@ export class Session {
1031
1094
  this.chatId,
1032
1095
  '❌ 创建对话卡片失败 (Feishu SDK 重试 3 次后仍连不上)。你这条消息没能送到 Claude,请稍后重发。',
1033
1096
  )
1034
- // Halt Claude we already wrote the user text to its stdin in
1035
- // onUserMessage, but with no card to stream into the response would
1036
- // be lost. Interrupt now so the model doesn't burn tokens producing
1037
- // an answer that has nowhere to land.
1038
- this.proc?.sendInterrupt()
1097
+ // currentTurn left null as the failure signal. Caller decides
1098
+ // whether to sendInterrupt: onUserMessage's eager-open path
1099
+ // hasn't fed SDK yet so doesn't need to; the init handler has
1100
+ // (SDK started the turn itself) and must.
1039
1101
  return
1040
1102
  }
1041
1103
  let cardId: string
@@ -1066,32 +1128,42 @@ export class Session {
1066
1128
  // and mutate `this.currentTurn` underfoot.
1067
1129
  private appendAssistant(delta: string): void {
1068
1130
  if (!this.currentTurn) return
1069
- if (!this.currentTurn.currentAssistantSegmentId) {
1131
+ const turn = this.currentTurn
1132
+ if (!turn.currentAssistantSegmentId) {
1070
1133
  // New assistant segment opens a visual break — any prior Read run
1071
1134
  // is now visually separated from future Reads, so close the batch
1072
1135
  // window. Future Reads will start a fresh batch at a new i.
1073
- this.currentTurn.openReadBatchI = null
1074
- const i = this.currentTurn.assistantSegmentCount++
1136
+ turn.openReadBatchI = null
1137
+ const i = turn.assistantSegmentCount++
1075
1138
  const segId = cards.ELEMENTS.assistant(i)
1076
- this.currentTurn.currentAssistantSegmentId = segId
1077
- this.currentTurn.currentAssistantText = ''
1078
- void cardkit.addElement(this.currentTurn.cardId, cards.assistantSegmentElement(i), {
1139
+ turn.currentAssistantSegmentId = segId
1140
+ turn.currentAssistantText = ''
1141
+ void cardkit.addElement(turn.cardId, cards.assistantSegmentElement(i), {
1079
1142
  type: 'insert_before', targetElementId: cards.ELEMENTS.footer,
1143
+ }, () => {
1144
+ // addElement永久失败:reset segmentId 让下次 delta 重新创建
1145
+ // segment,否则后续 streamText 全都 PUT 到不存在的 element,
1146
+ // 整段 assistant text 在用户那看不到。守 segId 不变以防 turn
1147
+ // rotation 或 addTool 已经清掉了它(每次 addElement 闭包带的
1148
+ // 是自己创建那次的 segId,只清自己的)。
1149
+ if (turn.currentAssistantSegmentId === segId) {
1150
+ log(`session "${this.sessionName}": assistant segment ${segId} addElement failed — will retry on next delta`)
1151
+ turn.currentAssistantSegmentId = null
1152
+ turn.currentAssistantText = ''
1153
+ turn.segmentTexts.delete(segId)
1154
+ }
1080
1155
  })
1081
1156
  }
1082
- this.currentTurn.currentAssistantText += delta
1083
- const segId = this.currentTurn.currentAssistantSegmentId
1084
- this.currentTurn.segmentTexts.set(segId, this.currentTurn.currentAssistantText)
1085
- cardkit.streamTextThrottled(
1086
- this.currentTurn.cardId,
1087
- segId,
1088
- this.currentTurn.currentAssistantText,
1089
- )
1157
+ turn.currentAssistantText += delta
1158
+ const segId = turn.currentAssistantSegmentId
1159
+ if (!segId) return // addElement 已失败 reset,等下一次 delta 重建
1160
+ turn.segmentTexts.set(segId, turn.currentAssistantText)
1161
+ cardkit.streamTextThrottled(turn.cardId, segId, turn.currentAssistantText)
1090
1162
  // Chat-list preview: tail of the latest assistant text. Feishu
1091
1163
  // truncates anyway; ~60 chars is what shows on a typical phone
1092
1164
  // preview line. patchSummaryThrottled is rate-limited on its own.
1093
- const tail = this.currentTurn.currentAssistantText.slice(-60)
1094
- cardkit.patchSummaryThrottled(this.currentTurn.cardId, tail)
1165
+ const tail = turn.currentAssistantText.slice(-60)
1166
+ cardkit.patchSummaryThrottled(turn.cardId, tail)
1095
1167
  }
1096
1168
 
1097
1169
  private appendThinking(delta: string): void {