@leviyuan/lodestar 0.2.3 → 0.2.5

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
@@ -50,9 +50,9 @@ AI 不是帮手,是倍率。它放大的不是体力,是你——你的直觉、
50
50
  | --- | --- |
51
51
  | `hi` | 未运行时启动;运行中弹一张**状态卡片** |
52
52
  | `stop` | 软打断当前 turn + 清空 type-ahead 排队;子进程保活,刚排队中的消息会被打 `CrossMark` 反应表示取消 |
53
- | `kill` | 优雅关闭 Claude 进程;记住 `sessionId`,下次 `restart` 还能 resume |
54
- | `restart` | 用上一次的 `sessionId` 重启会话(保留上下文) |
55
- | `clear` | 杀掉进程并启动一个全新 session(等价于 Claude Code 的 `/clear`) |
53
+ | `kill` | 优雅关闭 Claude 进程;`sessionId` 仍记在磁盘,下次 `restart` 还能 resume |
54
+ | `restart` | 用上一次的 `sessionId` 重启会话(保留上下文);无进程时也能用,等于"恢复上一会话" |
55
+ | `clear` | 杀掉当前进程并启动一个全新 session(等价于 Claude Code 的 `/clear`);**无进程时无效** |
56
56
 
57
57
  > 这五个词被全局保留:在群里发 "hi" 当问候也会触发控制台卡片,不会到 Claude 那边。换来的是手机上单手打字的便利。
58
58
 
package/daemon.ts CHANGED
@@ -192,9 +192,6 @@ async function handleCardAction(data: any): Promise<any> {
192
192
  case 'permission':
193
193
  await session.onPermissionDecision(value.request_id, value.decision, userId)
194
194
  return { toast: { type: value.decision === 'deny' ? 'error' : 'success', content: '已处理' } }
195
- case 'console':
196
- await session.onConsoleAction(value.action)
197
- return { toast: { type: 'info', content: value.action } }
198
195
  case 'menu':
199
196
  await session.onUserMessage(`(menu choice ${value.choice + 1})`)
200
197
  return { toast: { type: 'success', content: 'OK' } }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leviyuan/lodestar",
3
- "version": "0.2.3",
3
+ "version": "0.2.5",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
package/src/cards.ts CHANGED
@@ -171,7 +171,6 @@ interface MainCardOpts {
171
171
  turn: number
172
172
  model?: string
173
173
  effort?: string
174
- userText: string
175
174
  /** What started this turn. `'scheduled'` adds a top-of-card banner so
176
175
  * the user can tell a cron-fired wakeup apart from one of their own
177
176
  * messages — the user's message bubble is otherwise the only visual
@@ -200,7 +199,8 @@ export function mainConversationCard(opts: MainCardOpts): object {
200
199
  // panels are inserted between them in real time as Claude streams.
201
200
  // Note: empty-string content is rejected by CardKit PUT so the
202
201
  // thinking element starts with a single space placeholder; the first
203
- // real append overwrites it.
202
+ // real append overwrites it. No echo of the user's message inside
203
+ // the card — the chat bubble above already shows it.
204
204
  elements: [
205
205
  ...banner,
206
206
  { tag: 'markdown', element_id: ELEMENTS.thinking, content: ' ' },
@@ -550,7 +550,6 @@ interface ConsoleOpts {
550
550
  cumStats?: { tokens: number; costUsd: number; turns: number }
551
551
  lastTurn?: { tokens: number; costUsd: number; durationMs: number }
552
552
  sessionId?: string | null
553
- hasSession: boolean
554
553
  }
555
554
 
556
555
  /** Format token counts as a compact human-readable string: 1,234 → 1.2K. */
@@ -656,7 +655,7 @@ export function consoleUsageContent(
656
655
  export function consoleCard(opts: ConsoleOpts): object {
657
656
  const {
658
657
  sessionName, status, model, effort, uptimeMs, peers, usage,
659
- contextTokens, contextLimit, cumStats, lastTurn, sessionId, hasSession,
658
+ contextTokens, contextLimit, cumStats, lastTurn, sessionId,
660
659
  } = opts
661
660
  const statusEmoji = {
662
661
  idle: '🟢 闲', working: '⚙️ 工作中', awaiting_permission: '🔐 等审批',
@@ -698,12 +697,6 @@ export function consoleCard(opts: ConsoleOpts): object {
698
697
  lines.push(`**🆔 session** \`${sessionId.slice(0, 8)}…\``)
699
698
  }
700
699
 
701
- void hasSession // accept the field for caller compat; lifecycle is now
702
- // driven by bare-word commands (`hi` / `kill` / `restart` / `clear`),
703
- // not buttons — keeps the panel pure-readout and one-handed mobile-
704
- // friendly. The 'refresh' / 'ls' actions stay in onConsoleAction for
705
- // backward compat with any still-floating older cards in chat history.
706
-
707
700
  const template = status === 'working' ? 'blue'
708
701
  : status === 'awaiting_permission' ? 'orange'
709
702
  : status === 'stopped' ? 'grey'
package/src/feishu.ts CHANGED
@@ -8,6 +8,7 @@
8
8
 
9
9
  import * as lark from '@larksuiteoapi/node-sdk'
10
10
  import { execSync } from 'node:child_process'
11
+ import { randomUUID } from 'node:crypto'
11
12
  import { existsSync, mkdirSync, readFileSync, realpathSync, statSync, unlinkSync, writeFileSync } from 'node:fs'
12
13
  import { homedir } from 'node:os'
13
14
  import { basename, extname, join } from 'node:path'
@@ -165,32 +166,84 @@ export async function refreshChatList(): Promise<void> {
165
166
  }
166
167
 
167
168
  // ── Outbound: text + card ──────────────────────────────────────────────
168
- export async function sendText(chatId: string, text: string): Promise<string | null> {
169
- try {
170
- const res: any = await client.im.message.create({
171
- params: { receive_id_type: 'chat_id' },
172
- data: { receive_id: chatId, msg_type: 'text', content: JSON.stringify({ text }) },
173
- })
174
- if (res?.code && res.code !== 0) {
175
- log(`feishu: sendText rejected chat=${chatId} code=${res.code} msg=${res.msg}`)
176
- return null
169
+ /** Delay schedule for sendText/sendCard SDK retries. Three attempts total
170
+ * (the leading 0 is the eager first try). Tuned for the bun+axios+lark-SDK
171
+ * ECONNREFUSED transient we've been seeing — by ~5s the socket pool
172
+ * usually recovers. Business errors (Feishu code != 0) are NOT retried;
173
+ * only thrown network errors are. */
174
+ const SEND_RETRY_DELAYS_MS = [0, 1000, 4000]
175
+
176
+ async function sendViaSdkWithRetry(
177
+ what: 'text' | 'card',
178
+ chatId: string,
179
+ msgType: 'text' | 'interactive',
180
+ content: string,
181
+ ): Promise<string | null> {
182
+ // Same uuid across retries → Feishu dedupes on its side so a successful-
183
+ // but-response-lost first attempt doesn't produce a duplicate message.
184
+ const uuid = randomUUID()
185
+ let lastErr: unknown = null
186
+ for (let i = 0; i < SEND_RETRY_DELAYS_MS.length; i++) {
187
+ if (SEND_RETRY_DELAYS_MS[i] > 0) {
188
+ await new Promise(r => setTimeout(r, SEND_RETRY_DELAYS_MS[i]))
177
189
  }
178
- return res?.data?.message_id ?? null
179
- } catch (e) { log(`feishu: sendText failed chat=${chatId}: ${e}`); return null }
190
+ try {
191
+ const res: any = await client.im.message.create({
192
+ params: { receive_id_type: 'chat_id' },
193
+ data: { receive_id: chatId, msg_type: msgType, content, uuid },
194
+ })
195
+ if (res?.code && res.code !== 0) {
196
+ log(`feishu: send${what === 'text' ? 'Text' : 'Card'} rejected chat=${chatId} code=${res.code} msg=${res.msg}`)
197
+ return null
198
+ }
199
+ return res?.data?.message_id ?? null
200
+ } catch (e) {
201
+ lastErr = e
202
+ log(`feishu: send${what === 'text' ? 'Text' : 'Card'} attempt ${i + 1}/${SEND_RETRY_DELAYS_MS.length} chat=${chatId} failed: ${e}`)
203
+ }
204
+ }
205
+ log(`feishu: send${what === 'text' ? 'Text' : 'Card'} chat=${chatId} EXHAUSTED ${SEND_RETRY_DELAYS_MS.length} retries: ${lastErr}`)
206
+ return null
207
+ }
208
+
209
+ export async function sendText(chatId: string, text: string): Promise<string | null> {
210
+ return sendViaSdkWithRetry('text', chatId, 'text', JSON.stringify({ text }))
180
211
  }
181
212
 
182
213
  export async function sendCard(chatId: string, card: object): Promise<string | null> {
214
+ return sendViaSdkWithRetry('card', chatId, 'interactive', JSON.stringify(card))
215
+ }
216
+
217
+ /** Last-resort text send that bypasses the lark SDK and uses raw fetch
218
+ * (which is what cardkit.ts uses and has never had stability issues on
219
+ * this runtime). Used by callers that need to *surface a failure when
220
+ * the SDK send path itself is the broken thing* — e.g. `openTurnCard`'s
221
+ * `sendCard` exhausted retries on ECONNREFUSED and we still owe the
222
+ * user a visible "your message was lost, please retry" notice. Do not
223
+ * use this as a general-purpose send; it's the failure-surfacing
224
+ * channel, not a silent fallback. */
225
+ export async function sendTextRaw(chatId: string, text: string): Promise<string | null> {
183
226
  try {
184
- const res: any = await client.im.message.create({
185
- params: { receive_id_type: 'chat_id' },
186
- data: { receive_id: chatId, msg_type: 'interactive', content: JSON.stringify(card) },
227
+ const token = await getTenantToken()
228
+ const res = await fetch('https://open.feishu.cn/open-apis/im/v1/messages?receive_id_type=chat_id', {
229
+ method: 'POST',
230
+ headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
231
+ body: JSON.stringify({
232
+ receive_id: chatId,
233
+ msg_type: 'text',
234
+ content: JSON.stringify({ text }),
235
+ }),
187
236
  })
188
- if (res?.code && res.code !== 0) {
189
- log(`feishu: sendCard rejected chat=${chatId} code=${res.code} msg=${res.msg}`)
237
+ const json = await res.json() as any
238
+ if (json?.code !== 0) {
239
+ log(`feishu: sendTextRaw rejected chat=${chatId} code=${json?.code} msg=${json?.msg}`)
190
240
  return null
191
241
  }
192
- return res?.data?.message_id ?? null
193
- } catch (e) { log(`feishu: sendCard failed chat=${chatId}: ${e}`); return null }
242
+ return json.data?.message_id ?? null
243
+ } catch (e) {
244
+ log(`feishu: sendTextRaw chat=${chatId} failed: ${e}`)
245
+ return null
246
+ }
194
247
  }
195
248
 
196
249
  // ── Reactions ──────────────────────────────────────────────────────────
package/src/session.ts CHANGED
@@ -34,7 +34,6 @@ interface TurnState {
34
34
  * not signal. Ask / permission urgents inside the turn still fire
35
35
  * regardless (those genuinely need attention even mid-schedule). */
36
36
  trigger: 'user_message' | 'scheduled'
37
- userText: string
38
37
  thinkingText: string
39
38
  toolCount: number
40
39
  /** `output` / `isError` are filled in by completeTool — kept on the
@@ -399,9 +398,23 @@ export class Session {
399
398
  await this.stop()
400
399
  return true
401
400
  case 'restart':
401
+ // resume the prior conversation — kills the current proc (if
402
+ // any) and spawns a new one with `--resume <lastSessionId>`.
403
+ // If no process is running, this is how the user gets back the
404
+ // previous conversation after a `kill` or a daemon crash.
402
405
  await this.restart(true)
403
406
  return true
404
407
  case 'clear':
408
+ // "throw away current conversation, start a new one". By design
409
+ // this only makes sense when there IS a current conversation:
410
+ // calling clear from stopped state is a no-op (user-confirmed
411
+ // 2026-05-16) — we don't want a stray `clear` to silently spawn
412
+ // a fresh session the user didn't ask for. To start from cold,
413
+ // use `hi`.
414
+ if (!this.isRunning()) {
415
+ await feishu.sendText(this.chatId, `⚪ session "${this.sessionName}" 当前未运行,clear 无效;用 \`hi\` 启动或 \`restart\` 恢复上一会话`)
416
+ return true
417
+ }
405
418
  await this.restart(false)
406
419
  return true
407
420
  }
@@ -439,7 +452,6 @@ export class Session {
439
452
  }
440
453
  : undefined,
441
454
  sessionId: this.proc?.sessionId ?? this.lastSessionId,
442
- hasSession: this.isRunning(),
443
455
  })
444
456
  const messageId = await feishu.sendCard(this.chatId, card)
445
457
  if (!messageId) return
@@ -480,12 +492,47 @@ export class Session {
480
492
  const ok = await this.start()
481
493
  if (!ok) return
482
494
  }
495
+ // Garbage-collect leftover state from a batch the SDK abandoned —
496
+ // most commonly an AskUserQuestion mid-turn, which makes the SDK
497
+ // emit `QUEUE remove × N` and drop every msg we'd already
498
+ // sendText'd into its queue. The daemon doesn't see those remove
499
+ // events, so `pendingUserMessageCount` and `pendingReactionIds`
500
+ // stay stuck. If the SDK is idle right now (no turn, no eager-
501
+ // open in flight) AND init has already fired at least once
502
+ // (otherwise we'd be in the bootstrap race window where
503
+ // leftover count IS valid — see wasBusy comment below), the
504
+ // leftover count is stale and must be cleared BEFORE the
505
+ // wasBusy computation — otherwise this fresh solo message gets
506
+ // falsely wrapped `<u>…</u>` and its card closes with
507
+ // `📨 转交新卡` instead of `✅`.
508
+ if (this.initCount >= 1 && !this.currentTurn && !this.openingTurn && this.pendingUserMessageCount > 0) {
509
+ this.pendingUserMessageCount = 0
510
+ // Release stale ⏳ reactions left on the abandoned batch's
511
+ // chat messages. addReaction callbacks still in flight will
512
+ // fall through to the orphan path in the wasBusy branch
513
+ // below (which deletes whatever rid lands after both maps
514
+ // are empty).
515
+ for (const [m, rid] of this.pendingReactionIds) {
516
+ if (rid) void feishu.deleteReaction(m, rid)
517
+ }
518
+ this.pendingReactionIds = new Map()
519
+ }
483
520
  // Capture busy-state SYNC, before any state mutation — this decides
484
521
  // whether the message will visibly queue (gets the OneSecond → later
485
522
  // CheckMark lifecycle reactions on its Feishu chat message) or
486
523
  // eager-open its own card (no reaction needed; the card itself is
487
524
  // the acknowledgement).
488
- const wasBusy = this.currentTurn !== null || this.openingTurn
525
+ //
526
+ // `pendingUserMessageCount > 0` catches the bootstrap race: daemon
527
+ // just spawned, `initCount` is still 0 so no card is open yet, but
528
+ // we've already sendText'd a previous user message into the SDK.
529
+ // The next message lands in the SAME merged-batch SDK queue, so
530
+ // it IS mid-flight from the SDK's perspective — without this
531
+ // check, the daemon would mark it as solo (no `<u>` wrap, no ⏳
532
+ // reaction) and the model would see e.g. "123" + "321" + "1"
533
+ // glued into a single string "1233211" (2026-05-16 accumulator
534
+ // bug).
535
+ const wasBusy = this.currentTurn !== null || this.openingTurn || this.pendingUserMessageCount > 0
489
536
  this.pendingUserMessageCount++
490
537
  this.lastUserOpenId = userOpenId
491
538
  // When the SDK will merge this msg with siblings into a multi-
@@ -496,8 +543,9 @@ export class Session {
496
543
  // chars and `<u>1</u><u>45</u>` became "145" to the model
497
544
  // (2026-05-16 accumulator test). HTML-tag wrap is visible but
498
545
  // models parse `<tag>` boundaries very reliably from training.
499
- // Solo (eager-open) msgs don't get wrapped no sibling, no
500
- // merge, no need. Contract declared in CHANNEL_INSTRUCTIONS.
546
+ // Only the very first solo message of a fresh SDK turn slot
547
+ // skips the wrap — no sibling, no merge, no need. Contract
548
+ // declared in CHANNEL_INSTRUCTIONS.
501
549
  const wireText = wasBusy ? `<u>${text}</u>` : text
502
550
  this.proc!.sendUserText(wireText, files)
503
551
  if (wasBusy && msgId) {
@@ -509,11 +557,34 @@ export class Session {
509
557
  this.pendingReactionIds.set(msgId, '')
510
558
  void (async () => {
511
559
  const rid = await feishu.addReaction(msgId, 'OneSecond')
512
- if (rid && this.pendingReactionIds.has(msgId)) {
560
+ if (!rid) return
561
+ if (this.pendingReactionIds.has(msgId)) {
513
562
  this.pendingReactionIds.set(msgId, rid)
563
+ } else if (this.currentBatchReactionIds.has(msgId)) {
564
+ // Init handler renamed the sentinel into the batch map while
565
+ // addReaction was in flight — record the rid there so the
566
+ // batch's close-time deleteReaction sees it.
567
+ this.currentBatchReactionIds.set(msgId, rid)
568
+ } else {
569
+ // Orphan: both maps cleared (closeTurnCard already released
570
+ // them) before our add returned. The reaction is now stuck
571
+ // on the Feishu message with no one tracking it — delete
572
+ // directly so the user doesn't see a stale ⏳ forever.
573
+ // (Observed bug 2026-05-16: 8 OneSeconds added during a M0
574
+ // turn, 2 addReaction callbacks landed after close fired the
575
+ // release loop, those rids never made it back into either
576
+ // map → 2 stuck ⏳ in chat.)
577
+ void feishu.deleteReaction(msgId, rid)
514
578
  }
515
579
  })()
516
580
  }
581
+ // Mid-turn user messages don't touch the in-flight card — the SDK
582
+ // queues them and dequeues them on its next turn boundary, at
583
+ // which point `result` closes the current card with `📨 转交新卡`
584
+ // and `init` opens a fresh card for the merged batch turn. The
585
+ // user's own message bubble in the chat (plus the OneSecond ⏳
586
+ // reaction added above) is the only mid-flight feedback they get;
587
+ // no card edit, no echo inside the card.
517
588
  if (!this.currentTurn && !this.openingTurn && this.initCount >= 1) {
518
589
  // Eager open: this message is going to be processed solo (no current
519
590
  // turn to merge with on the SDK side, so SDK runs it as its own turn).
@@ -524,7 +595,7 @@ export class Session {
524
595
  this.openingTurn = true
525
596
  this.pendingUserMessageCount--
526
597
  try {
527
- await this.openTurnCard(text, userOpenId, 'user_message')
598
+ await this.openTurnCard(userOpenId, 'user_message')
528
599
  this.status = 'working'
529
600
  } finally {
530
601
  this.openingTurn = false
@@ -719,19 +790,6 @@ export class Session {
719
790
  }
720
791
  }
721
792
 
722
- async onConsoleAction(action: string): Promise<void> {
723
- log(`session "${this.sessionName}": console action=${action}`)
724
- switch (action) {
725
- case 'interrupt': this.interrupt(); break
726
- case 'clear': await this.restart(false); break
727
- case 'stop': await this.stop(); break
728
- case 'start': await this.start(); break
729
- case 'resume': await this.restart(true); break
730
- case 'refresh': await this.showConsole(); break
731
- case 'ls': await feishu.sendText(this.chatId, `📁 ${this.workDir}`); break
732
- }
733
- }
734
-
735
793
  // ── Wiring Claude → Feishu ─────────────────────────────────────────
736
794
  private wireProc(p: ClaudeProcess): void {
737
795
  p.on('init', () => {
@@ -744,31 +802,22 @@ export class Session {
744
802
  }
745
803
  this.initCount++
746
804
 
747
- // The boot init (initCount === 1) only happens once per spawn and
748
- // is claimed by whichever user message gets processed first — that
749
- // message's card is opened eagerly in `onUserMessage`, so the boot
750
- // init itself opens nothing. EXCEPTION: if a user message landed
751
- // before the boot init (rare race during start()), the queue has
752
- // an entry drain it here.
805
+ // Boot init (initCount === 1) is claimed by `onUserMessage`'s
806
+ // eager-open path if a user message landed before the init
807
+ // arrived, it sits in `pendingUserMessageCount` and we drain it
808
+ // below; otherwise the init opens nothing. Subsequent inits
809
+ // (initCount >= 2) mark the start of an SDK-initiated turn:
810
+ // either the SDK is draining the type-ahead queue we fed it via
811
+ // `sendUserText` (isUserBatch), or it's a CronCreate /
812
+ // ScheduleWakeup fire from idle (isScheduledFire).
753
813
  //
754
- // Subsequent inits (initCount >= 2) mark the start of an SDK-
755
- // initiated turn either the SDK draining its internal type-ahead
756
- // queue (we'll have an entry in `pendingUserMessages` mirroring
757
- // it) or a CronCreate / ScheduleWakeup fire (queue empty). The
758
- // `currentTurn` / `openingTurn` checks guard the race where
759
- // `onUserMessage` already eager-opened (or is mid-open) for the
760
- // same user message and the SDK emitted an init#≥2 we don't need
761
- // to act on. The init handler ALSO claims `openingTurn` for its
762
- // own async open so a user message landing during the open
763
- // doesn't spawn a duplicate card.
814
+ // SDK-driven rotation puts the boundary HERE: the previous
815
+ // turn's `result` already closed the in-flight card with
816
+ // `📨 转交新卡` (because pendingUserMessageCount > 0). Now we
817
+ // open a fresh card whose top panel shows the queued messages.
818
+ // currentTurn should be null at this point (result null'd it);
819
+ // the openingTurn guard catches the eager-open vs init race.
764
820
  if (this.currentTurn || this.openingTurn) return
765
- // `pendingUserMessageCount > 0` ⇒ SDK is about to fire an init for a
766
- // merged batch of one-or-more user messages we already sendText'd
767
- // (the eager-open path didn't claim them because a turn was still
768
- // running at the time). Claim the ENTIRE count here — the SDK
769
- // collapses them into ONE turn, so only one card opens; any further
770
- // messages that arrive after this point will start a fresh count
771
- // and a fresh batch.
772
821
  const isUserBatch = this.pendingUserMessageCount > 0
773
822
  const isScheduledFire = !isUserBatch && this.initCount > 1
774
823
  if (!isUserBatch && !isScheduledFire) return
@@ -784,11 +833,7 @@ export class Session {
784
833
  this.openingTurn = true
785
834
  void (async () => {
786
835
  try {
787
- await this.openTurnCard(
788
- isUserBatch ? '' : '⏰ 定时唤醒',
789
- userOpenId,
790
- isUserBatch ? 'user_message' : 'scheduled',
791
- )
836
+ await this.openTurnCard(userOpenId, isUserBatch ? 'user_message' : 'scheduled')
792
837
  this.status = 'working'
793
838
  } finally {
794
839
  this.openingTurn = false
@@ -816,7 +861,14 @@ export class Session {
816
861
  })
817
862
  p.on('result', () => {
818
863
  this.accumulateResultStats()
819
- void this.closeTurnCard()
864
+ // SDK-driven rotation: if any mid-turn user messages stacked up
865
+ // (the SDK is about to dequeue them into a fresh merged-batch
866
+ // turn), close the in-flight card with `📨 转交新卡` so the user
867
+ // sees the cut. The next `init` for that batch turn will open a
868
+ // new card whose top panel echoes those queued messages. No
869
+ // pending → natural ✅ close.
870
+ const suffix = this.pendingUserMessageCount > 0 ? '📨 转交新卡' : undefined
871
+ void this.closeTurnCard(suffix)
820
872
  this.status = 'idle'
821
873
  })
822
874
  p.on('exit', ({ code, signal, expected }: any) => {
@@ -880,17 +932,35 @@ export class Session {
880
932
  return this.proc?.lastContextWindow ?? 200_000
881
933
  }
882
934
 
883
- private async openTurnCard(userText: string, userOpenId: string, trigger: 'user_message' | 'scheduled'): Promise<void> {
935
+ private async openTurnCard(userOpenId: string, trigger: 'user_message' | 'scheduled'): Promise<void> {
884
936
  const turn = ++this.turnCounter
885
937
  const card = cards.mainConversationCard({
886
938
  sessionName: this.sessionName,
887
939
  turn,
888
940
  effort: 'max',
889
- userText,
890
941
  kind: trigger,
891
942
  })
892
943
  const messageId = await feishu.sendCard(this.chatId, card)
893
- if (!messageId) { log(`session "${this.sessionName}": openTurnCard sendCard failed`); return }
944
+ if (!messageId) {
945
+ log(`session "${this.sessionName}": openTurnCard sendCard EXHAUSTED retries — surfacing via raw text`)
946
+ // sendCard already retried 3× through the SDK. If it still came back
947
+ // null we're either on a sustained SDK-axios outage or a Feishu
948
+ // business reject. Either way the user just sent us a message and
949
+ // it's gone into a black hole — surface that explicitly so they
950
+ // know to resend instead of waiting for a reply that won't come.
951
+ // Use raw fetch (not sendText) because if the SDK is the broken
952
+ // thing we'd be doomed to silence otherwise.
953
+ await feishu.sendTextRaw(
954
+ this.chatId,
955
+ '❌ 创建对话卡片失败 (Feishu SDK 重试 3 次后仍连不上)。你这条消息没能送到 Claude,请稍后重发。',
956
+ )
957
+ // Halt Claude — we already wrote the user text to its stdin in
958
+ // onUserMessage, but with no card to stream into the response would
959
+ // be lost. Interrupt now so the model doesn't burn tokens producing
960
+ // an answer that has nowhere to land.
961
+ this.proc?.sendInterrupt()
962
+ return
963
+ }
894
964
  let cardId: string
895
965
  try { cardId = await cardkit.convertMessageToCard(messageId) }
896
966
  catch (e) { log(`session "${this.sessionName}": id_convert failed: ${e}`); return }
@@ -899,7 +969,6 @@ export class Session {
899
969
  messageId,
900
970
  userOpenId,
901
971
  trigger,
902
- userText,
903
972
  thinkingText: '',
904
973
  toolCount: 0,
905
974
  toolByUseId: new Map(),