@leviyuan/lodestar 0.2.6 → 0.2.7

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.7",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
package/src/cardkit.ts CHANGED
@@ -107,6 +107,7 @@ async function withReopenOnStreamingClosed(
107
107
  cardId: string,
108
108
  label: string,
109
109
  op: () => Promise<void>,
110
+ onFailure?: () => void,
110
111
  ): Promise<void> {
111
112
  try {
112
113
  await op()
@@ -114,6 +115,7 @@ async function withReopenOnStreamingClosed(
114
115
  } catch (e) {
115
116
  if (!isStreamingClosed(e)) {
116
117
  log(`cardkit ${label} ${cardId}: ${e}`)
118
+ if (onFailure) onFailure()
117
119
  return
118
120
  }
119
121
  log(`cardkit ${label} ${cardId}: streaming closed (300309) — reopening`)
@@ -122,12 +124,14 @@ async function withReopenOnStreamingClosed(
122
124
  await reopenStreaming(cardId)
123
125
  } catch (re) {
124
126
  log(`cardkit STREAMING_REOPEN_FAILED ${cardId}: ${re}`)
127
+ if (onFailure) onFailure()
125
128
  return
126
129
  }
127
130
  try {
128
131
  await op()
129
132
  } catch (e2) {
130
133
  log(`cardkit ${label} ${cardId} retry-after-reopen: ${e2}`)
134
+ if (onFailure) onFailure()
131
135
  }
132
136
  }
133
137
 
@@ -201,11 +205,20 @@ export async function flush(cardId: string): Promise<void> {
201
205
  }
202
206
  }
203
207
 
204
- /** Add a new element to the card body or relative to a sibling. */
208
+ /** Add a new element to the card body or relative to a sibling.
209
+ *
210
+ * `onFailure` fires asynchronously (after promise queue settles) if the
211
+ * element was NOT created — either the first attempt failed with a non-
212
+ * 300309 error, or the retry-after-reopen also failed. Use it to invalidate
213
+ * any daemon-side reference to the element you tried to add (e.g. a segment
214
+ * id), so subsequent writes don't keep PUTting content to a phantom element
215
+ * that Feishu will silently reject. Default (no callback) preserves the
216
+ * legacy fire-and-forget swallow behavior. */
205
217
  export function addElement(
206
218
  cardId: string,
207
219
  element: object,
208
220
  opts: { type?: 'append' | 'insert_before' | 'insert_after'; targetElementId?: string } = {},
221
+ onFailure?: () => void,
209
222
  ): Promise<void> {
210
223
  const s = state(cardId)
211
224
  s.queue = s.queue.then(() => withReopenOnStreamingClosed(
@@ -220,6 +233,7 @@ export function addElement(
220
233
  sequence: seq,
221
234
  })
222
235
  },
236
+ onFailure,
223
237
  ))
224
238
  return s.queue
225
239
  }
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
@@ -294,6 +297,27 @@ export class Session {
294
297
  return true
295
298
  }
296
299
 
300
+ /** Drop every ⏳ OneSecond reaction this session is currently holding
301
+ * on user chat messages, then empty the two tracking maps. Used by
302
+ * every tear-down path (proc exit, kill, restart) so reactions don't
303
+ * outlive the conversation that placed them — without this, a Claude
304
+ * crash / daemon SIGTERM leaves orphan ⏳ stuck on user messages until
305
+ * Feishu's UI eventually GCs them (which it doesn't, in practice).
306
+ * closeTurnCard has its own release pass (with the slightly-early
307
+ * merged-batch trade-off documented there); this is the catastrophic-
308
+ * exit pass. Direct `deleteReaction` calls are fire-and-forget and
309
+ * swallow their own failures (see feishu.deleteReaction). */
310
+ private releaseAllReactions(): void {
311
+ for (const [msgId, rid] of [
312
+ ...this.pendingReactionIds.entries(),
313
+ ...this.currentBatchReactionIds.entries(),
314
+ ]) {
315
+ if (rid) void feishu.deleteReaction(msgId, rid)
316
+ }
317
+ this.pendingReactionIds = new Map()
318
+ this.currentBatchReactionIds = new Map()
319
+ }
320
+
297
321
  async stop(reason = '已终止'): Promise<void> {
298
322
  if (!this.proc) {
299
323
  this.status = 'stopped'
@@ -315,8 +339,7 @@ export class Session {
315
339
  this.pendingUserMessageCount = 0
316
340
  this.pendingMidTurnMsgs = []
317
341
  this.lastUserOpenId = ''
318
- this.pendingReactionIds = new Map()
319
- this.currentBatchReactionIds = new Map()
342
+ this.releaseAllReactions()
320
343
  this.initCount = 0
321
344
  this.openingTurn = false
322
345
  this.pendingPermissions.clear()
@@ -336,8 +359,7 @@ export class Session {
336
359
  this.pendingUserMessageCount = 0
337
360
  this.pendingMidTurnMsgs = []
338
361
  this.lastUserOpenId = ''
339
- this.pendingReactionIds = new Map()
340
- this.currentBatchReactionIds = new Map()
362
+ this.releaseAllReactions()
341
363
  this.initCount = 0
342
364
  this.openingTurn = false
343
365
  this.pendingPermissions.clear()
@@ -619,25 +641,37 @@ export class Session {
619
641
  // - first message after spawn (init not yet fired)
620
642
  // - bootstrap race (sibling msgs landing before init#1)
621
643
  // - 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
- }
644
+ // Eager-open path: open the card BEFORE feeding SDK, so a card-open
645
+ // failure doesn't strand the daemon with SDK processing a turn we
646
+ // have nowhere to render. `!openingTurn` means no sibling is mid-
647
+ // open; `initCount >= 1` means SDK boot init has fired (otherwise
648
+ // the init handler owns turn opening and we just feed the queue
649
+ // below). On failure openTurnCard surfaces a red banner via
650
+ // sendTextRaw; SDK was idle so no interrupt needed.
629
651
  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
652
  this.openingTurn = true
634
- this.pendingUserMessageCount--
635
653
  try {
636
654
  await this.openTurnCard(userOpenId, 'user_message')
655
+ if (!this.currentTurn) return
656
+ this.proc!.sendUserText(wireText, files)
657
+ this.pendingUserMessageCount++
637
658
  this.status = 'working'
638
659
  } finally {
639
660
  this.openingTurn = false
640
661
  }
662
+ return
663
+ }
664
+
665
+ // Non-eager path: either init hasn't fired yet (cold start) or a
666
+ // sibling onUserMessage is already opening. Feed SDK directly; the
667
+ // init handler / sibling card-opener will batch this message in.
668
+ this.proc!.sendUserText(wireText, files)
669
+ this.pendingUserMessageCount++
670
+ if (wasBusy && msgId) {
671
+ // Bootstrap race / sibling-opening race: until a card is open,
672
+ // the OneSecond ⏳ is the only ack the user gets. The init handler
673
+ // inherits these via currentBatchReactionIds when it opens.
674
+ trackReaction(msgId)
641
675
  }
642
676
  }
643
677
 
@@ -873,7 +907,19 @@ export class Session {
873
907
  void (async () => {
874
908
  try {
875
909
  await this.openTurnCard(userOpenId, isUserBatch ? 'user_message' : 'scheduled')
876
- this.status = 'working'
910
+ if (!this.currentTurn) {
911
+ // SDK already started this turn (its `init` is what got us
912
+ // here) but we have no card to render into. Interrupt so
913
+ // assistant/tool events aren't silently dropped while the
914
+ // model burns tokens. Release the reactions this batch
915
+ // inherited (init handler moved them above) — otherwise
916
+ // they stay ⏳ forever on the user's chat messages.
917
+ log(`session "${this.sessionName}": init-path openTurnCard failed — sendInterrupt + release reactions`)
918
+ this.proc?.sendInterrupt()
919
+ this.releaseAllReactions()
920
+ } else {
921
+ this.status = 'working'
922
+ }
877
923
  } finally {
878
924
  this.openingTurn = false
879
925
  }
@@ -923,8 +969,7 @@ export class Session {
923
969
  this.pendingUserMessageCount = 0
924
970
  this.pendingMidTurnMsgs = []
925
971
  this.lastUserOpenId = ''
926
- this.pendingReactionIds = new Map()
927
- this.currentBatchReactionIds = new Map()
972
+ this.releaseAllReactions()
928
973
  this.initCount = 0
929
974
  this.openingTurn = false
930
975
  this.status = 'stopped'
@@ -1031,11 +1076,10 @@ export class Session {
1031
1076
  this.chatId,
1032
1077
  '❌ 创建对话卡片失败 (Feishu SDK 重试 3 次后仍连不上)。你这条消息没能送到 Claude,请稍后重发。',
1033
1078
  )
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()
1079
+ // currentTurn left null as the failure signal. Caller decides
1080
+ // whether to sendInterrupt: onUserMessage's eager-open path
1081
+ // hasn't fed SDK yet so doesn't need to; the init handler has
1082
+ // (SDK started the turn itself) and must.
1039
1083
  return
1040
1084
  }
1041
1085
  let cardId: string
@@ -1066,32 +1110,42 @@ export class Session {
1066
1110
  // and mutate `this.currentTurn` underfoot.
1067
1111
  private appendAssistant(delta: string): void {
1068
1112
  if (!this.currentTurn) return
1069
- if (!this.currentTurn.currentAssistantSegmentId) {
1113
+ const turn = this.currentTurn
1114
+ if (!turn.currentAssistantSegmentId) {
1070
1115
  // New assistant segment opens a visual break — any prior Read run
1071
1116
  // is now visually separated from future Reads, so close the batch
1072
1117
  // window. Future Reads will start a fresh batch at a new i.
1073
- this.currentTurn.openReadBatchI = null
1074
- const i = this.currentTurn.assistantSegmentCount++
1118
+ turn.openReadBatchI = null
1119
+ const i = turn.assistantSegmentCount++
1075
1120
  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), {
1121
+ turn.currentAssistantSegmentId = segId
1122
+ turn.currentAssistantText = ''
1123
+ void cardkit.addElement(turn.cardId, cards.assistantSegmentElement(i), {
1079
1124
  type: 'insert_before', targetElementId: cards.ELEMENTS.footer,
1125
+ }, () => {
1126
+ // addElement永久失败:reset segmentId 让下次 delta 重新创建
1127
+ // segment,否则后续 streamText 全都 PUT 到不存在的 element,
1128
+ // 整段 assistant text 在用户那看不到。守 segId 不变以防 turn
1129
+ // rotation 或 addTool 已经清掉了它(每次 addElement 闭包带的
1130
+ // 是自己创建那次的 segId,只清自己的)。
1131
+ if (turn.currentAssistantSegmentId === segId) {
1132
+ log(`session "${this.sessionName}": assistant segment ${segId} addElement failed — will retry on next delta`)
1133
+ turn.currentAssistantSegmentId = null
1134
+ turn.currentAssistantText = ''
1135
+ turn.segmentTexts.delete(segId)
1136
+ }
1080
1137
  })
1081
1138
  }
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
- )
1139
+ turn.currentAssistantText += delta
1140
+ const segId = turn.currentAssistantSegmentId
1141
+ if (!segId) return // addElement 已失败 reset,等下一次 delta 重建
1142
+ turn.segmentTexts.set(segId, turn.currentAssistantText)
1143
+ cardkit.streamTextThrottled(turn.cardId, segId, turn.currentAssistantText)
1090
1144
  // Chat-list preview: tail of the latest assistant text. Feishu
1091
1145
  // truncates anyway; ~60 chars is what shows on a typical phone
1092
1146
  // preview line. patchSummaryThrottled is rate-limited on its own.
1093
- const tail = this.currentTurn.currentAssistantText.slice(-60)
1094
- cardkit.patchSummaryThrottled(this.currentTurn.cardId, tail)
1147
+ const tail = turn.currentAssistantText.slice(-60)
1148
+ cardkit.patchSummaryThrottled(turn.cardId, tail)
1095
1149
  }
1096
1150
 
1097
1151
  private appendThinking(delta: string): void {