@leviyuan/lodestar 0.2.8 → 0.2.9

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/src/session.ts CHANGED
@@ -5,6 +5,13 @@
5
5
  * the in-flight permission map. Wires Claude's stdout events into Card
6
6
  * Kit ops, and wires Feishu inbound (text + card-action callbacks) into
7
7
  * Claude's stdin.
8
+ *
9
+ * Tool tracking, AskUserQuestion flow, and permission rendering live in
10
+ * sibling modules (session-tools.ts, session-ask.ts,
11
+ * session-permission.ts) so this file stays under Claude Code's
12
+ * per-read token budget (~25K). Fields touched by those helpers carry
13
+ * no `private` modifier — convention is "no modifier = package-internal,
14
+ * only the session-*.ts helpers should touch it."
8
15
  */
9
16
 
10
17
  import { existsSync } from 'node:fs'
@@ -15,96 +22,17 @@ import * as cardkit from './cardkit'
15
22
  import * as cards from './cards'
16
23
  import * as feishu from './feishu'
17
24
  import { log } from './log'
18
- import { INBOX_DIR } from './paths'
25
+ import { readSysInfo } from './sysinfo'
19
26
  import { readUsage } from './usage'
27
+ import type { TurnState, Status, SessionOpts, LastTurnDelta, CumStats } from './session-types'
28
+ import * as sessionTools from './session-tools'
29
+ import * as sessionAsk from './session-ask'
30
+ import * as sessionPermission from './session-permission'
20
31
 
21
- interface TurnState {
22
- cardId: string
23
- /** Feishu message_id of the card — needed for urgent_app push on clean
24
- * turn close. Kept separate from cardId because cardkit's stream APIs
25
- * operate on card_id but the urgent_app endpoint takes message_id. */
26
- messageId: string
27
- /** open_id of the user who started this turn. Used to scope the
28
- * urgent_app push so only the initiator gets pinged (in case there
29
- * are other members in the group). Empty string → skip the ping. */
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'
37
- thinkingText: string
38
- toolCount: number
39
- /** `output` / `isError` are filled in by completeTool — kept on the
40
- * meta (instead of being thrown away after the first render) so a
41
- * later Task* op can re-render every prior Task* panel with the
42
- * latest todo mirror appended. */
43
- toolByUseId: Map<string, {
44
- i: number
45
- name: string
46
- input: any
47
- resolvedNote?: string
48
- output?: string
49
- isError?: boolean
50
- /** Set when this tool is part of a merged Read batch — points to the
51
- * batch's slot in `readBatches[i].items`. completeTool uses it to
52
- * update the right row instead of rendering a standalone panel. */
53
- readBatchSlot?: number
54
- }>
55
- /** Consecutive `Read` calls collapse into a single panel rendered by
56
- * `cards.readBatchElement`. Keyed by element index `i` so completeTool
57
- * can find the batch after its open-window closed (a non-Read tool or
58
- * new assistant segment has since arrived).
59
- *
60
- * `openReadBatchI` is the i of the batch currently accepting new Reads;
61
- * null once the run ends. Subsequent Read calls open a fresh batch at a
62
- * new i. */
63
- readBatches: Map<number, {
64
- items: Array<{ toolUseId: string; input: any; output: string | null; isError: boolean }>
65
- }>
66
- openReadBatchI: number | null
67
- assistantSegmentCount: number
68
- currentAssistantSegmentId: string | null
69
- currentAssistantText: string
70
- // Per-assistant-segment cumulative text — used at turn close to strip
71
- // [[send: /path]] markers and replace each segment with a cleaned
72
- // version, then post the files as separate Feishu messages.
73
- segmentTexts: Map<string, string>
74
- startedAt: number
75
- }
32
+ export type { SessionOpts } from './session-types'
76
33
 
77
34
  const SEND_MARKER_RE = /\[\[send:\s*([^\]\n]+?)\s*\]\]/g
78
35
 
79
- type Status = 'idle' | 'working' | 'awaiting_permission' | 'starting' | 'stopped'
80
-
81
- export interface SessionOpts {
82
- permissionMode?: 'default' | 'acceptEdits' | 'plan' | 'bypassPermissions'
83
- }
84
-
85
- /** Per-turn delta extracted from the SDK `result` message — feeds the
86
- * "上一轮" line in the console panel. */
87
- interface LastTurnDelta {
88
- tokens: number // input + cache_* + output for that turn
89
- costUsd: number
90
- durationMs: number
91
- inputTokens: number // input + cache_* (excludes output) — context-window estimate
92
- }
93
-
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. */
102
- interface CumStats {
103
- tokens: number
104
- costUsd: number
105
- turns: number
106
- }
107
-
108
36
  export class Session {
109
37
  /** Process-wide registry of every Session ever constructed in this daemon.
110
38
  * Used by the `hi` console panel to enumerate sibling sessions across
@@ -114,8 +42,47 @@ export class Session {
114
42
  * want currently-alive Claude processes. */
115
43
  static readonly all: Set<Session> = new Set()
116
44
 
117
- private proc: ClaudeProcess | null = null
118
- private currentTurn: TurnState | null = null
45
+ // ── package-internal state (touched by session-*.ts helpers) ──
46
+ proc: ClaudeProcess | null = null
47
+ currentTurn: TurnState | null = null
48
+ pendingPermissions = new Map<string, { toolUseId: string }>()
49
+ /** Open AskUserQuestion tool calls — keyed by tool_use_id. The SDK
50
+ * routes AskUserQuestion through the can_use_tool flow even under
51
+ * bypass; we have to thread the permission `requestId` through here
52
+ * so the answer (option click OR custom text submit) can resolve
53
+ * the permission with `updatedInput.answers` populated.
54
+ * `deferredAnswer` covers the race where the user clicks/submits
55
+ * BEFORE can_use_tool arrives (addTool fires on the assistant
56
+ * message; can_use_tool is a separate control_request that lands
57
+ * slightly later). */
58
+ pendingAsks = new Map<string, {
59
+ questions: cards.AskQuestion[]
60
+ i: number
61
+ requestId?: string
62
+ /** 累计答案 — key 是 question 原文 (SDK 把这条 record 格式
63
+ * 化进 tool_result), value 是用户选的 option label 或自定
64
+ * 义文字。全部 question 都答完时一并塞进 updatedInput.answers
65
+ * 发回 SDK。 */
66
+ answers: Record<string, string>
67
+ /** 已答详情 (按 question idx 索引),用来给历史面板和 terminal
68
+ * 状态画选中态。answers 同步累计,但这里多保留 customText /
69
+ * optionIdx 字段以便 UI 区分两种回答路径。 */
70
+ answered: Map<number, cards.AskAnswered>
71
+ /** 当前展示的 question idx。undefined 表示全部答完 (terminal)
72
+ * —— 这时若 requestId 已就位则 finalize;否则等 renderPermission
73
+ * 一来立即 finalize。 */
74
+ currentIdx?: number
75
+ }>()
76
+ /** Local mirror of the SDK's task list — built incrementally from
77
+ * TaskCreate / TaskUpdate input+output pairs and rendered as a footer
78
+ * on every Task* panel. Lives for the lifetime of the Session
79
+ * instance; daemon restart wipes it (the SDK doesn't replay history).
80
+ * Not authoritative — Claude calling TaskList is still the source of
81
+ * truth; this mirror is purely for the panel readout. */
82
+ currentTodos = new Map<number, cards.Todo>()
83
+ status: Status = 'stopped'
84
+
85
+ // ── strictly private state ──
119
86
  /** Count of user messages we've written to Claude's stdin since the last
120
87
  * turn opened on our side. NOT a FIFO of individual messages — the SDK
121
88
  * USUALLY batch-merges every mid-turn user message into a single
@@ -186,35 +153,15 @@ export class Session {
186
153
  * message. The flag tells the init handler "an eager open is already
187
154
  * claiming the slot, stand down". */
188
155
  private openingTurn = false
189
- private pendingPermissions = new Map<string, { toolUseId: string }>()
190
- /** Open AskUserQuestion tool calls — keyed by tool_use_id. The SDK
191
- * routes AskUserQuestion through the can_use_tool flow even under
192
- * bypass; we have to thread the permission `requestId` through here
193
- * so the answer (option click OR custom text submit) can resolve
194
- * the permission with `updatedInput.answers` populated.
195
- * `deferredAnswer` covers the race where the user clicks/submits
196
- * BEFORE can_use_tool arrives (addTool fires on the assistant
197
- * message; can_use_tool is a separate control_request that lands
198
- * slightly later). */
199
- private pendingAsks = new Map<string, {
200
- questions: cards.AskQuestion[]
201
- i: number
202
- requestId?: string
203
- /** 累计答案 — key 是 question 原文 (SDK 把这条 record 格式
204
- * 化进 tool_result), value 是用户选的 option label 或自定
205
- * 义文字。全部 question 都答完时一并塞进 updatedInput.answers
206
- * 发回 SDK。 */
207
- answers: Record<string, string>
208
- /** 已答详情 (按 question idx 索引),用来给历史面板和 terminal
209
- * 状态画选中态。answers 同步累计,但这里多保留 customText /
210
- * optionIdx 字段以便 UI 区分两种回答路径。 */
211
- answered: Map<number, cards.AskAnswered>
212
- /** 当前展示的 question idx。undefined 表示全部答完 (terminal)
213
- * —— 这时若 requestId 已就位则 finalize;否则等 renderPermission
214
- * 一来立即 finalize。 */
215
- currentIdx?: number
216
- }>()
217
156
  private turnCounter = 0
157
+ /** Consecutive SDK error turns since the last `success`. When the SDK
158
+ * closes a turn with `subtype !== 'success'` (error_during_execution /
159
+ * error_max_turns), the daemon swallows the phone push and re-pokes
160
+ * the SDK with a "继续" user message to auto-resume. Two errors in a
161
+ * row → give up: surface the failure (⛔ footer + forced phone push)
162
+ * and reset. Any natural-success turn OR user intervention
163
+ * (mid-turn buffer drain) resets this back to 0. */
164
+ private consecutiveErrors = 0
218
165
  // Last seen sessionId — preserved across `kill`/`stop` so a later
219
166
  // `restart` can resume the same Claude conversation even after the
220
167
  // child process is gone.
@@ -222,14 +169,6 @@ export class Session {
222
169
  private startedAt: number = 0
223
170
  private cumStats: CumStats = { tokens: 0, costUsd: 0, turns: 0 }
224
171
  private lastTurnDelta: LastTurnDelta | null = null
225
- /** Local mirror of the SDK's task list — built incrementally from
226
- * TaskCreate / TaskUpdate input+output pairs and rendered as a footer
227
- * on every Task* panel. Lives for the lifetime of the Session
228
- * instance; daemon restart wipes it (the SDK doesn't replay history).
229
- * Not authoritative — Claude calling TaskList is still the source of
230
- * truth; this mirror is purely for the panel readout. */
231
- private currentTodos = new Map<number, cards.Todo>()
232
- status: Status = 'stopped'
233
172
 
234
173
  constructor(
235
174
  public readonly sessionName: string,
@@ -246,19 +185,6 @@ export class Session {
246
185
  }
247
186
  }
248
187
 
249
- /** Patch the card-level summary (the text Feishu uses for chat-list
250
- * preview AND lock-screen push), then return when the API call has
251
- * landed. Used right before urgent_app so the push notification's
252
- * derived preview describes the *action that needs attention* (an
253
- * unanswered question, a pending permission ask) rather than the
254
- * stale assistant-text tail that patchSummaryThrottled was streaming.
255
- * cancelSummary kills any in-flight throttled write so our explicit
256
- * patch isn't immediately clobbered. */
257
- private async setUrgentSummary(cardId: string, content: string): Promise<void> {
258
- cardkit.cancelSummary(cardId)
259
- await cardkit.patchSettings(cardId, { config: { summary: { content } } })
260
- }
261
-
262
188
  /** Minimal cross-chat snapshot for the `hi` peer-list section.
263
189
  * `startedAt` stays private so this is the documented read path. */
264
190
  peerSnapshot(): { name: string; status: Status; uptimeMs?: number } {
@@ -297,6 +223,23 @@ export class Session {
297
223
  })
298
224
  this.wireProc(this.proc)
299
225
  this.proc.sendInitialize({})
226
+ // 等 `system/init` 落地再认定 ready —— sendInitialize 只把 RPC
227
+ // 写进 stdin,Claude 回包之前 proc.sessionId 还是 null,这时候
228
+ // showConsole() 看到 null 会 fallback 到磁盘上**上一次**会话的
229
+ // lastSessionId,面板就把陈年 session_id 当成"当前会话"贴出去,
230
+ // model / usage / contextWindow 也都没值。等 init 之后再返回,
231
+ // 后续 `hi`、首条 user message 都能拿到真值。5s 兜底,init 真
232
+ // 没来也不死循环。
233
+ await new Promise<void>(resolve => {
234
+ const proc = this.proc!
235
+ const timer = setTimeout(() => {
236
+ proc.off('init', onInit)
237
+ log(`session "${this.sessionName}": init wait timeout (5s) — proceeding`)
238
+ resolve()
239
+ }, 5000)
240
+ const onInit = () => { clearTimeout(timer); resolve() }
241
+ proc.once('init', onInit)
242
+ })
300
243
 
301
244
  await feishu.sendText(this.chatId, `✅ Lodestar session "${this.sessionName}" 已就绪,发消息开始对话。`)
302
245
  this.status = 'idle'
@@ -350,6 +293,7 @@ export class Session {
350
293
  this.initCount = 0
351
294
  this.openingTurn = false
352
295
  this.pendingPermissions.clear()
296
+ this.consecutiveErrors = 0
353
297
  this.status = 'stopped'
354
298
  await proc.kill()
355
299
  await feishu.sendText(this.chatId, `🔴 ${reason} (session: ${this.sessionName})`)
@@ -370,6 +314,7 @@ export class Session {
370
314
  this.initCount = 0
371
315
  this.openingTurn = false
372
316
  this.pendingPermissions.clear()
317
+ this.consecutiveErrors = 0
373
318
  if (resume && prevSessionId) {
374
319
  this.proc = new ClaudeProcess({
375
320
  workDir: this.workDir,
@@ -492,6 +437,10 @@ export class Session {
492
437
  // reads better than `claude-opus-4-7` in the small status header.
493
438
  const rawModel = this.proc?.lastModel ?? null
494
439
  const model = rawModel ? rawModel.replace(/^claude-/, '') : undefined
440
+ // readSysInfo 全程是本机调用 (/proc + statfs + 本地 systemctl),最坏
441
+ // 情况由 runSystemctl 的 2s 超时兜底。在 sendCard 前 await 一下,把
442
+ // CPU/mem/disk/services 一次性塞进首屏,不走 element patch 的二段刷新。
443
+ const sysinfo = await readSysInfo()
495
444
  const card = cards.consoleCard({
496
445
  sessionName: this.sessionName,
497
446
  status: this.status,
@@ -517,6 +466,7 @@ export class Session {
517
466
  }
518
467
  : undefined,
519
468
  sessionId: this.proc?.sessionId ?? this.lastSessionId,
469
+ sysinfo,
520
470
  })
521
471
  const messageId = await feishu.sendCard(this.chatId, card)
522
472
  if (!messageId) return
@@ -682,191 +632,28 @@ export class Session {
682
632
  }
683
633
  }
684
634
 
685
- async onPermissionDecision(
686
- requestId: string,
687
- decision: 'allow' | 'allow_always' | 'deny',
688
- user: string,
689
- ): Promise<void> {
690
- const pending = this.pendingPermissions.get(requestId)
691
- if (!pending) { log(`session "${this.sessionName}": stray permission ${requestId}`); return }
692
- this.pendingPermissions.delete(requestId)
635
+ // ── External API delegated to helpers ──────────────────────────────
636
+ // Thin wrappers so daemon.ts keeps its `session.xxx(...)` call style;
637
+ // bodies live in session-ask.ts / session-permission.ts.
693
638
 
694
- // Update the tool element in the main turn card in place — the
695
- // permission decision lives on the same row as the tool call.
696
- const turn = this.currentTurn
697
- const meta = turn?.toolByUseId.get(pending.toolUseId)
698
- if (turn && meta) {
699
- const todos = this.isTaskWorkflow(meta.name) ? this.todosArray() : undefined
700
- if (decision === 'deny') {
701
- const el = cards.toolCallElement(meta.i, meta.name, meta.input, `🚫 已拒绝 by ${user || '匿名'}`, '❌', undefined, todos)
702
- void cardkit.replaceElement(turn.cardId, cards.ELEMENTS.tool(meta.i), el)
703
- } else {
704
- const label = decision === 'allow_always' ? '始终允许' : '已允许'
705
- meta.resolvedNote = `✅ **${label}** by ${user || '匿名'}`
706
- const el = cards.toolCallElement(meta.i, meta.name, meta.input, null, '⏳', meta.resolvedNote, todos)
707
- void cardkit.replaceElement(turn.cardId, cards.ELEMENTS.tool(meta.i), el)
708
- }
709
- }
710
-
711
- const claudeDecision = decision === 'deny' ? 'deny' : 'allow'
712
- this.proc?.sendPermissionResponse(requestId, claudeDecision)
713
-
714
- if (decision === 'allow_always') {
715
- this.proc?.sendSetPermissionMode('acceptEdits')
716
- }
717
-
718
- if (this.pendingPermissions.size === 0 && this.status === 'awaiting_permission') {
719
- this.status = 'working'
720
- }
721
- }
722
-
723
- /** True iff there's at least one open AskUserQuestion awaiting an
724
- * answer in this session. `daemon.handleMessage` uses this to
725
- * decide whether an inbound chat message should be a custom answer
726
- * (routed to onAskMessageAnswer) instead of opening a new turn. */
727
639
  hasPendingAsk(): boolean {
728
- return this.pendingAsks.size > 0
640
+ return sessionAsk.hasPendingAsk(this)
729
641
  }
730
642
 
731
- /** True iff a turn is currently running (or a queued user message is
732
- * waiting for its turn to start). daemon uses this to drop a hourglass
733
- * reaction on inbound messages — without it the user sees no visible
734
- * acknowledgement that their type-ahead message landed (the card
735
- * doesn't open until the current turn finishes). */
736
- isBusy(): boolean {
737
- return this.currentTurn !== null || this.pendingUserMessageCount > 0 || this.pendingMidTurnMsgs.length > 0
643
+ onAskMessageAnswer(text: string, user: string): Promise<void> {
644
+ return sessionAsk.onAskMessageAnswer(this, text, user)
738
645
  }
739
646
 
740
- /** Funnel an arbitrary chat message into the *current* question
741
- * of the oldest pending ask as a `customText` answer. Multi-
742
- * question semantics: from the user's perspective, the chat
743
- * input always answers whatever question is on screen right now
744
- * (`pending.currentIdx`), and a new question slides in after. */
745
- async onAskMessageAnswer(text: string, user: string): Promise<void> {
746
- const firstEntry = this.pendingAsks.entries().next()
747
- if (firstEntry.done) {
748
- log(`session "${this.sessionName}": onAskMessageAnswer with no pending — falling back to onUserMessage`)
749
- await this.onUserMessage(text)
750
- return
751
- }
752
- const [toolUseId, pending] = firstEntry.value
753
- if (pending.currentIdx === undefined) {
754
- log(`session "${this.sessionName}": pending ask ${toolUseId} already terminal — ignoring message`)
755
- return
756
- }
757
- await this.onAskCustomAnswer(toolUseId, pending.currentIdx, text, user)
758
- }
759
-
760
- /** Click handler for an option button. The click must target the
761
- * question currently on screen (`pending.currentIdx`); a stale
762
- * click (e.g. user clicked an older render before it swapped in
763
- * the next question) is logged and dropped — better than double-
764
- * answering. */
765
- async onAskAnswer(toolUseId: string, questionIdx: number, optionIdx: number, user: string): Promise<void> {
766
- const pending = this.pendingAsks.get(toolUseId)
767
- if (!pending) { log(`session "${this.sessionName}": stray ask answer for ${toolUseId}`); return }
768
- if (questionIdx !== pending.currentIdx) {
769
- log(`session "${this.sessionName}": stale ask click q=${questionIdx} current=${pending.currentIdx}`)
770
- return
771
- }
772
- this.advanceAsk(toolUseId, { optionIdx, user })
647
+ onAskAnswer(toolUseId: string, questionIdx: number, optionIdx: number, user: string): Promise<void> {
648
+ return sessionAsk.onAskAnswer(this, toolUseId, questionIdx, optionIdx, user)
773
649
  }
774
650
 
775
- /** Custom-text branch. Same staleness rule as onAskAnswer; empty
776
- * input is silently ignored (panel stays pending). */
777
- async onAskCustomAnswer(toolUseId: string, questionIdx: number, customText: string, user: string): Promise<void> {
778
- const pending = this.pendingAsks.get(toolUseId)
779
- if (!pending) { log(`session "${this.sessionName}": stray ask custom for ${toolUseId}`); return }
780
- const trimmed = (customText ?? '').trim()
781
- if (!trimmed) { log(`session "${this.sessionName}": empty custom answer, ignoring`); return }
782
- if (questionIdx !== pending.currentIdx) {
783
- log(`session "${this.sessionName}": stale ask custom q=${questionIdx} current=${pending.currentIdx}`)
784
- return
785
- }
786
- this.advanceAsk(toolUseId, { customText: trimmed, user })
651
+ onAskCustomAnswer(toolUseId: string, questionIdx: number, customText: string, user: string): Promise<void> {
652
+ return sessionAsk.onAskCustomAnswer(this, toolUseId, questionIdx, customText, user)
787
653
  }
788
654
 
789
- /** Record an answer for the current question, advance the state
790
- * machine, repaint. If every question is now answered, finalize
791
- * (or defer the finalize until can_use_tool lands — the race is
792
- * handled by renderPermission). */
793
- private advanceAsk(
794
- toolUseId: string,
795
- answer: { optionIdx?: number; customText?: string; user: string },
796
- ): void {
797
- const pending = this.pendingAsks.get(toolUseId)
798
- if (!pending || pending.currentIdx === undefined) return
799
- const cur = pending.currentIdx
800
- const q = pending.questions[cur]
801
- if (!q) { log(`session "${this.sessionName}": advanceAsk currentIdx=${cur} out of range`); return }
802
- // Resolve the literal answer value — custom text wins if both set.
803
- let value: string
804
- if (answer.customText !== undefined) {
805
- value = answer.customText
806
- } else if (answer.optionIdx !== undefined) {
807
- const opt = q.options?.[answer.optionIdx]
808
- if (!opt) { log(`session "${this.sessionName}": advanceAsk option ${answer.optionIdx} out of range`); return }
809
- value = opt.label
810
- } else {
811
- log(`session "${this.sessionName}": advanceAsk with neither customText nor optionIdx`)
812
- return
813
- }
814
- pending.answers[q.question] = value
815
- pending.answered.set(cur, {
816
- optionIdx: answer.optionIdx,
817
- customText: answer.customText,
818
- user: answer.user,
819
- })
820
- // Next unanswered idx — linear from cur+1. Implementation
821
- // always moves forward; we don't currently let users revisit a
822
- // previous question (would need richer UI affordance for that).
823
- const total = pending.questions.length
824
- let nextIdx: number | undefined = undefined
825
- for (let i = cur + 1; i < total; i++) {
826
- if (!pending.answered.has(i)) { nextIdx = i; break }
827
- }
828
- pending.currentIdx = nextIdx
829
-
830
- const turn = this.currentTurn
831
- const meta = turn?.toolByUseId.get(toolUseId)
832
- if (turn && meta) {
833
- const el = cards.askUserQuestionElement(
834
- meta.i, toolUseId, pending.questions,
835
- nextIdx === undefined ? '✅' : '🤔',
836
- { currentIdx: nextIdx, answered: pending.answered },
837
- )
838
- void cardkit.replaceElement(turn.cardId, cards.ELEMENTS.tool(meta.i), el)
839
- }
840
-
841
- if (nextIdx === undefined) {
842
- // All done. Finalize iff we have the permission request id;
843
- // otherwise renderPermission will pick it up when it arrives.
844
- if (pending.requestId) this.finalizeAsk(toolUseId)
845
- else log(`session "${this.sessionName}": ask ${toolUseId} all answered, waiting for can_use_tool`)
846
- }
847
- }
848
-
849
- /** Settle a fully-answered AskUserQuestion: emit the SDK allow
850
- * with the full `answers` record folded into `updatedInput`,
851
- * drop bookkeeping, restore status. The terminal panel paint was
852
- * already done by the final advanceAsk; this is just protocol. */
853
- private finalizeAsk(toolUseId: string): void {
854
- const pending = this.pendingAsks.get(toolUseId)
855
- if (!pending || !pending.requestId) return
856
- const meta = this.currentTurn?.toolByUseId.get(toolUseId)
857
- const originalInput = meta?.input ?? {}
858
- this.proc?.sendPermissionResponse(pending.requestId, 'allow', {
859
- updatedInput: { ...originalInput, answers: pending.answers },
860
- })
861
- this.pendingPermissions.delete(pending.requestId)
862
- if (meta) {
863
- meta.output = JSON.stringify({ answers: pending.answers })
864
- meta.isError = false
865
- }
866
- this.pendingAsks.delete(toolUseId)
867
- if (this.pendingPermissions.size === 0 && this.status === 'awaiting_permission') {
868
- this.status = 'working'
869
- }
655
+ onPermissionDecision(requestId: string, decision: 'allow' | 'allow_always' | 'deny', user: string): Promise<void> {
656
+ return sessionPermission.onPermissionDecision(this, requestId, decision, user)
870
657
  }
871
658
 
872
659
  // ── Wiring Claude → Feishu ─────────────────────────────────────────
@@ -939,13 +726,13 @@ export class Session {
939
726
  this.appendThinking(text)
940
727
  })
941
728
  p.on('tool_use', ({ id, name, input }: { id: string; name: string; input: any }) => {
942
- this.addTool(id, name, input)
729
+ sessionTools.addTool(this, id, name, input)
943
730
  })
944
731
  p.on('tool_result', ({ tool_use_id, content, is_error }: any) => {
945
- this.completeTool(tool_use_id, content, is_error)
732
+ sessionTools.completeTool(this, tool_use_id, content, is_error)
946
733
  })
947
734
  p.on('can_use_tool', (req: CanUseToolRequest) => {
948
- this.renderPermission(req)
735
+ sessionPermission.renderPermission(this, req)
949
736
  })
950
737
  p.on('hook_callback', (req: HookCallbackRequest) => {
951
738
  // No hooks registered → fail-safe ack.
@@ -953,21 +740,65 @@ export class Session {
953
740
  })
954
741
  p.on('result', () => {
955
742
  this.accumulateResultStats()
956
- // Daemon-driven rotation: mid-turn msgs were buffered (not yet
957
- // sent to SDK) — close the in-flight card with `📨 转交新卡` and
958
- // drain the buffer in one shot. The drain writes each buffered
959
- // msg to SDK stdin, which is the `priority="now"` wake the SDK
960
- // polling loop needs (claude-code issue #39632) AND constitutes
961
- // the input for the new batch turn. We open the new card here
962
- // ourselves rather than waiting on init — the SDK init for this
963
- // batch will fire shortly but `currentTurn` will already be set,
964
- // so the init handler will return without double-opening.
743
+ // Three orthogonal signals fold into one footer suffix + push/retry
744
+ // decision here:
745
+ // 1. `pendingMidTurnMsgs` user typed during the turn; their
746
+ // messages need a fresh card and SDK wake-up. Takes priority
747
+ // over auto-retry (user is back at the keyboard).
748
+ // 2. `lastResult.is_error` SDK closed the turn with a non-
749
+ // `success` subtype (error_during_execution / error_max_turns).
750
+ // First occurrence: swallow the phone push, re-poke SDK with
751
+ // "继续" to auto-resume. Second consecutive: give up,
752
+ // ⛔ footer + force phone push so the user knows.
753
+ // 3. Natural success — `✅` footer, normal phone push, reset
754
+ // consecutiveErrors.
755
+ // closeTurnCard's default push-on-clean-close stays the floor;
756
+ // `forcePush:true` is the override for the "we hit retry ceiling"
757
+ // case (which has a non-empty suffix and would otherwise be silent).
965
758
  const hasMidTurn = this.pendingMidTurnMsgs.length > 0
966
- const suffix = hasMidTurn ? '📨 转交新卡' : undefined
967
- log(`session "${this.sessionName}": SDK result midBuffer=${this.pendingMidTurnMsgs.length} suffix=${suffix ?? '<✅>'}`)
968
- void this.closeTurnCard(suffix)
759
+ const isError = this.proc?.lastResult.is_error === true
760
+ const subtype = this.proc?.lastResult.subtype ?? 'success'
761
+
762
+ let suffix: string | undefined
763
+ let autoRetry = false
764
+ let forcePush = false
765
+
766
+ if (hasMidTurn) {
767
+ // User intervention wins over auto-retry — they're actively
768
+ // sending new input, no point also auto-poking the SDK.
769
+ this.consecutiveErrors = 0
770
+ suffix = isError ? `⚠️ SDK ${subtype},用户已介入` : '📨 转交新卡'
771
+ } else if (isError) {
772
+ this.consecutiveErrors++
773
+ if (this.consecutiveErrors >= 2) {
774
+ suffix = `⛔ SDK 连续报错 (${subtype}),已停止`
775
+ forcePush = true
776
+ this.consecutiveErrors = 0
777
+ } else {
778
+ suffix = `⚠️ SDK ${subtype},自动续 turn…`
779
+ autoRetry = true
780
+ }
781
+ } else {
782
+ this.consecutiveErrors = 0
783
+ }
784
+
785
+ log(`session "${this.sessionName}": SDK result subtype=${subtype} isError=${isError} midBuffer=${this.pendingMidTurnMsgs.length} consecErr=${this.consecutiveErrors} autoRetry=${autoRetry} forcePush=${forcePush}`)
786
+ void this.closeTurnCard(suffix, { forcePush })
969
787
  this.status = 'idle'
970
- if (hasMidTurn) void this.drainMidTurnAndOpen()
788
+
789
+ if (hasMidTurn) {
790
+ void this.drainMidTurnAndOpen()
791
+ } else if (autoRetry) {
792
+ // Re-poke the SDK to start a fresh turn. Anthropic's text-block
793
+ // API rejects empty content, so use "继续" — minimal Chinese
794
+ // imperative the model parses cleanly. Bumping
795
+ // pendingUserMessageCount makes the init handler classify the
796
+ // resulting turn as user_message (inheriting lastUserOpenId)
797
+ // rather than scheduled, so the eventual success will still
798
+ // phone-push the original sender.
799
+ this.proc?.sendUserText('继续')
800
+ this.pendingUserMessageCount++
801
+ }
971
802
  })
972
803
  p.on('exit', ({ code, signal, expected }: any) => {
973
804
  log(`session "${this.sessionName}": claude exited code=${code} signal=${signal} expected=${expected}`)
@@ -979,6 +810,7 @@ export class Session {
979
810
  this.releaseAllReactions()
980
811
  this.initCount = 0
981
812
  this.openingTurn = false
813
+ this.consecutiveErrors = 0
982
814
  this.status = 'stopped'
983
815
  if (!expected && code !== 0 && signal !== 'SIGTERM') {
984
816
  void feishu.sendText(this.chatId, `⚠️ Claude 异常退出 (code=${code}, signal=${signal})。回复任意消息将重新启动。`)
@@ -1025,9 +857,11 @@ export class Session {
1025
857
  * .contextWindow` captured by ClaudeProcess on each turn close, so
1026
858
  * the daemon doesn't have to enumerate model ids itself (was the
1027
859
  * source of a "560K/200K" display bug — model id didn't include
1028
- * `[1m]` so the hardcoded fallback won). */
1029
- private contextWindowMax(): number {
1030
- return this.proc?.lastContextWindow ?? 200_000
860
+ * `[1m]` so the hardcoded fallback won). Returns `null` when no turn
861
+ * has closed yet (fresh spawn / kill / clear / revive); callers must
862
+ * render percentages only when this is a real number. */
863
+ private contextWindowMax(): number | null {
864
+ return this.proc?.lastContextWindow ?? null
1031
865
  }
1032
866
 
1033
867
  /** Drain `pendingMidTurnMsgs` to the SDK and open a fresh card for the
@@ -1176,308 +1010,7 @@ export class Session {
1176
1010
  )
1177
1011
  }
1178
1012
 
1179
- private isTaskWorkflow(name: string): boolean {
1180
- return name.startsWith('Task') && name !== 'Task'
1181
- }
1182
-
1183
- private todosArray(): cards.Todo[] {
1184
- return [...this.currentTodos.values()]
1185
- }
1186
-
1187
- private addTool(toolUseId: string, name: string, input: any): void {
1188
- if (!this.currentTurn) return
1189
- // Close current assistant segment (if any) so the tool panel renders
1190
- // AFTER it in card body order. Flush queues the segment's last
1191
- // buffered delta before the tool element is inserted.
1192
- if (this.currentTurn.currentAssistantSegmentId) {
1193
- void cardkit.flush(this.currentTurn.cardId)
1194
- this.currentTurn.currentAssistantSegmentId = null
1195
- this.currentTurn.currentAssistantText = ''
1196
- }
1197
- // Consecutive Read merger: if a Read run is already open, append to
1198
- // its batch and re-render the panel instead of inserting a new one.
1199
- // Any other tool name closes the run (handled below).
1200
- if (name === 'Read' && this.currentTurn.openReadBatchI !== null) {
1201
- const batchI = this.currentTurn.openReadBatchI
1202
- const batch = this.currentTurn.readBatches.get(batchI)!
1203
- const slot = batch.items.length
1204
- batch.items.push({ toolUseId, input, output: null, isError: false })
1205
- this.currentTurn.toolByUseId.set(toolUseId, { i: batchI, name, input, readBatchSlot: slot })
1206
- const el = cards.readBatchElement(batchI, batch.items)
1207
- void cardkit.replaceElement(this.currentTurn.cardId, cards.ELEMENTS.tool(batchI), el)
1208
- return
1209
- }
1210
- if (name !== 'Read') this.currentTurn.openReadBatchI = null
1211
- const i = this.currentTurn.toolCount++
1212
- if (name === 'Read') {
1213
- // First Read of a potential run — render the existing single-tool
1214
- // panel (which keeps the full file-contents dump on completion). If
1215
- // a second Read arrives, completeTool/addTool will switch it to
1216
- // `readBatchElement`.
1217
- this.currentTurn.openReadBatchI = i
1218
- this.currentTurn.readBatches.set(i, {
1219
- items: [{ toolUseId, input, output: null, isError: false }],
1220
- })
1221
- this.currentTurn.toolByUseId.set(toolUseId, { i, name, input, readBatchSlot: 0 })
1222
- const el = cards.toolCallElement(i, name, input, null, '⏳', undefined, undefined)
1223
- void cardkit.addElement(this.currentTurn.cardId, el, {
1224
- type: 'insert_before', targetElementId: cards.ELEMENTS.footer,
1225
- })
1226
- return
1227
- }
1228
- this.currentTurn.toolByUseId.set(toolUseId, { i, name, input })
1229
- // AskUserQuestion is a client-side tool — daemon renders the choice
1230
- // UI in-line and supplies the tool_result itself once the user
1231
- // clicks. Branch BEFORE the generic toolCallElement so we never
1232
- // fall through to a JSON dump or, worse, get clobbered by the
1233
- // permission flow (which would render 🔐 three-button buttons that
1234
- // don't match the actual N options).
1235
- if (name === 'AskUserQuestion') {
1236
- const questions = Array.isArray(input?.questions) ? input.questions as cards.AskQuestion[] : []
1237
- const startIdx = questions.length > 0 ? 0 : undefined
1238
- const answered = new Map<number, cards.AskAnswered>()
1239
- this.pendingAsks.set(toolUseId, {
1240
- questions,
1241
- i,
1242
- answers: {},
1243
- answered,
1244
- currentIdx: startIdx,
1245
- })
1246
- const el = cards.askUserQuestionElement(i, toolUseId, questions, '🤔', {
1247
- currentIdx: startIdx,
1248
- answered,
1249
- })
1250
- void cardkit.addElement(this.currentTurn.cardId, el, {
1251
- type: 'insert_before',
1252
- targetElementId: cards.ELEMENTS.footer,
1253
- })
1254
- // Phone push — user has to come back and answer before Claude can
1255
- // continue. Set summary to the question text so the lock-screen
1256
- // notification preview shows what the user needs to answer.
1257
- if (this.currentTurn.userOpenId && this.currentTurn.messageId) {
1258
- const turn = this.currentTurn
1259
- const q0 = questions[0]?.question?.trim() ?? ''
1260
- const truncated = q0.length > 40 ? q0.slice(0, 40) + '…' : q0
1261
- const summary = questions.length > 1
1262
- ? `❓ 待回答 ${questions.length} 题${truncated ? `: ${truncated}` : ''}`
1263
- : truncated
1264
- ? `❓ ${truncated}`
1265
- : '❓ 等你回答问题'
1266
- void (async () => {
1267
- await this.setUrgentSummary(turn.cardId, summary)
1268
- await feishu.urgentApp(turn.messageId, [turn.userOpenId])
1269
- })()
1270
- }
1271
- return
1272
- }
1273
- // Pending Task* panels still show the *pre-op* todo mirror so users
1274
- // can read the current state immediately, without waiting for the
1275
- // tool to return.
1276
- const todos = this.isTaskWorkflow(name) ? this.todosArray() : undefined
1277
- const el = cards.toolCallElement(i, name, input, null, '⏳', undefined, todos)
1278
- void cardkit.addElement(this.currentTurn.cardId, el, {
1279
- type: 'insert_before',
1280
- targetElementId: cards.ELEMENTS.footer,
1281
- })
1282
- }
1283
-
1284
- private completeTool(toolUseId: string, content: any, isError: boolean): void {
1285
- if (!this.currentTurn) return
1286
- const meta = this.currentTurn.toolByUseId.get(toolUseId)
1287
- if (!meta) return
1288
- const output = typeof content === 'string'
1289
- ? content
1290
- : Array.isArray(content)
1291
- ? content.map((c: any) => c?.text ?? JSON.stringify(c)).join('\n')
1292
- : JSON.stringify(content)
1293
- // Stash on the meta — every Task* op coming after this point may
1294
- // need to re-render this panel with a fresher todo footer, so we
1295
- // can't discard the output after the first paint.
1296
- meta.output = output
1297
- meta.isError = isError
1298
- // AskUserQuestion already had its final panel painted by resolveAsk
1299
- // (✅ + the chosen option marked, others dimmed). The tool_result
1300
- // arriving here is just the SDK's synthesised echo — re-rendering
1301
- // via toolCallElement would clobber the nice option-row layout
1302
- // with a generic JSON dump. Bail out; the panel is done.
1303
- if (meta.name === 'AskUserQuestion') return
1304
- // Read batch path: update this row's status in the shared batch then
1305
- // re-render. Single-item batches keep the original full-output panel
1306
- // (file-contents dump); 2+ items switch to the compact `Read · N 次`
1307
- // listing, which overwrites whatever was last drawn at this i.
1308
- if (meta.name === 'Read' && meta.readBatchSlot != null) {
1309
- const batch = this.currentTurn.readBatches.get(meta.i)
1310
- if (batch) {
1311
- const row = batch.items[meta.readBatchSlot]
1312
- if (row) { row.output = output; row.isError = isError }
1313
- const el = batch.items.length >= 2
1314
- ? cards.readBatchElement(meta.i, batch.items)
1315
- : cards.toolCallElement(meta.i, meta.name, meta.input, output, isError ? '❌' : '✅', meta.resolvedNote, undefined)
1316
- void cardkit.replaceElement(this.currentTurn.cardId, cards.ELEMENTS.tool(meta.i), el)
1317
- }
1318
- return
1319
- }
1320
- // Update the local todo mirror BEFORE rendering so the just-
1321
- // completed panel shows the new state too (e.g. a TaskCreate panel
1322
- // already lists the task it just created).
1323
- if (!isError && this.isTaskWorkflow(meta.name)) {
1324
- this.updateTodosFromTask(meta.name, meta.input, output)
1325
- }
1326
- const todos = this.isTaskWorkflow(meta.name) ? this.todosArray() : undefined
1327
- const el = cards.toolCallElement(meta.i, meta.name, meta.input, output, isError ? '❌' : '✅', meta.resolvedNote, todos)
1328
- void cardkit.replaceElement(this.currentTurn.cardId, cards.ELEMENTS.tool(meta.i), el)
1329
- // Cascade the new mirror into every prior Task* panel in this turn
1330
- // so any expanded panel reflects the latest state, not the snapshot
1331
- // captured when that op ran.
1332
- if (!isError && this.isTaskWorkflow(meta.name)) {
1333
- this.refreshOtherTaskPanels(toolUseId)
1334
- }
1335
- }
1336
-
1337
- /** Roll a single Task* op into the local mirror — best-effort. Output
1338
- * parsing is regex-based (the SDK returns plain text like "Task #7
1339
- * created successfully: …"), so unexpected variants are skipped
1340
- * silently rather than blowing up the panel render. */
1341
- private updateTodosFromTask(name: string, input: any, output: string): void {
1342
- switch (name) {
1343
- case 'TaskCreate': {
1344
- const m = output.match(/Task #(\d+) created/)
1345
- if (!m) return
1346
- const id = Number(m[1])
1347
- this.currentTodos.set(id, {
1348
- id,
1349
- subject: input.subject,
1350
- description: input.description,
1351
- activeForm: input.activeForm,
1352
- status: 'pending',
1353
- })
1354
- return
1355
- }
1356
- case 'TaskUpdate': {
1357
- const id = Number(input.taskId)
1358
- if (!Number.isFinite(id)) return
1359
- // status=deleted is the SDK's tombstone — drop from the mirror
1360
- // so the readout doesn't carry it forever. Server still keeps
1361
- // it; the mirror is just for the panel footer.
1362
- if (input.status === 'deleted') { this.currentTodos.delete(id); return }
1363
- const cur = this.currentTodos.get(id) ?? { id, status: 'pending' as const }
1364
- if (input.status) cur.status = input.status
1365
- if (input.subject) cur.subject = input.subject
1366
- if (input.description) cur.description = input.description
1367
- if (input.owner) cur.owner = input.owner
1368
- if (input.activeForm) cur.activeForm = input.activeForm
1369
- this.currentTodos.set(id, cur)
1370
- return
1371
- }
1372
- // TaskList / TaskGet / TaskStop / TaskOutput / TaskDelete:
1373
- // read-only or parse-heavy — skip mirror update. The panel will
1374
- // still render the SDK's textual result below the operation
1375
- // block, which is enough to disambiguate.
1376
- }
1377
- }
1378
-
1379
- /** Re-render every Task* panel in the current turn (except the one
1380
- * that just landed — already up-to-date) so they all show the latest
1381
- * todo mirror in their footers. Cheap: ELEMENTS.tool(i) replace is
1382
- * queued through the per-card Promise chain like any other op. */
1383
- private refreshOtherTaskPanels(skipToolUseId: string): void {
1384
- if (!this.currentTurn) return
1385
- const todos = this.todosArray()
1386
- for (const [id, meta] of this.currentTurn.toolByUseId) {
1387
- if (id === skipToolUseId) continue
1388
- if (!this.isTaskWorkflow(meta.name)) continue
1389
- const status: '⏳' | '✅' | '❌' = meta.output === undefined
1390
- ? '⏳'
1391
- : (meta.isError ? '❌' : '✅')
1392
- const el = cards.toolCallElement(
1393
- meta.i, meta.name, meta.input, meta.output ?? null,
1394
- status, meta.resolvedNote, todos,
1395
- )
1396
- void cardkit.replaceElement(this.currentTurn.cardId, cards.ELEMENTS.tool(meta.i), el)
1397
- }
1398
- }
1399
-
1400
- /** Merge the permission ask into the existing tool element in the
1401
- * current turn card. The user sees one continuous timeline: ⏳ pending
1402
- * → 🔐 awaiting approval (with buttons) → ⏳ allowed / ❌ denied → ✅
1403
- * with output. No floating orange card.
1404
- *
1405
- * `tool_use` is emitted as part of the assistant message and lands on
1406
- * our `addTool` handler BEFORE the SDK's `can_use_tool` control_request
1407
- * arrives — so by the time we get here, `toolByUseId` already has the
1408
- * entry we need to replace.
1409
- *
1410
- * Edge cases (no current turn / missing tool_use_id / unknown id) are
1411
- * surfaced loudly and auto-denied. We don't fall back to a standalone
1412
- * card — per the project's no-fallbacks rule, hidden anomalies are
1413
- * worse than visible deny errors. */
1414
- private renderPermission(req: CanUseToolRequest): void {
1415
- const turn = this.currentTurn
1416
- if (!turn) {
1417
- log(`session "${this.sessionName}": can_use_tool with no current turn — auto-deny req=${req.request_id}`)
1418
- this.proc?.sendPermissionResponse(req.request_id, 'deny', { denyMessage: 'no active turn' })
1419
- return
1420
- }
1421
- const toolUseId = req.tool_use_id
1422
- if (!toolUseId) {
1423
- log(`session "${this.sessionName}": can_use_tool without tool_use_id — auto-deny req=${req.request_id}`)
1424
- this.proc?.sendPermissionResponse(req.request_id, 'deny', { denyMessage: 'no tool_use_id' })
1425
- return
1426
- }
1427
- const meta = turn.toolByUseId.get(toolUseId)
1428
- if (!meta) {
1429
- log(`session "${this.sessionName}": can_use_tool for unknown tool_use_id=${toolUseId} — auto-deny req=${req.request_id}`)
1430
- this.proc?.sendPermissionResponse(req.request_id, 'deny', { denyMessage: 'unknown tool_use_id' })
1431
- return
1432
- }
1433
- // AskUserQuestion: SDK routes it through can_use_tool even under
1434
- // bypass. The PAYLOAD of "user has answered" is the permission
1435
- // response itself — specifically `updatedInput.answers`. So we
1436
- // CANNOT auto-allow here (that's the v0.1.2 bug: SDK got an empty
1437
- // answers map and immediately synthesised a "User has answered
1438
- // your questions: ." tool_result). Park the requestId on the
1439
- // pendingAsk record and wait for the user to click an option;
1440
- // onAskAnswer will then send allow + updatedInput.answers in one
1441
- // shot. If the user already clicked between addTool and now —
1442
- // the deferredAnswer slot — settle immediately.
1443
- if (meta.name === 'AskUserQuestion') {
1444
- const ask = this.pendingAsks.get(toolUseId)
1445
- if (!ask) {
1446
- log(`session "${this.sessionName}": AskUserQuestion ${toolUseId} missing pendingAsk — deny`)
1447
- this.proc?.sendPermissionResponse(req.request_id, 'deny', { denyMessage: 'no pending ask' })
1448
- return
1449
- }
1450
- ask.requestId = req.request_id
1451
- this.pendingPermissions.set(req.request_id, { toolUseId })
1452
- // Fast-clicker race: the user may have answered every question
1453
- // while we were still waiting for can_use_tool to arrive. If so,
1454
- // advanceAsk parked the all-done state and we drain it now.
1455
- if (ask.currentIdx === undefined) this.finalizeAsk(toolUseId)
1456
- return
1457
- }
1458
- this.status = 'awaiting_permission'
1459
- this.pendingPermissions.set(req.request_id, { toolUseId })
1460
- const el = cards.toolCallPermissionElement(meta.i, meta.name, meta.input, req.request_id)
1461
- void cardkit.replaceElement(turn.cardId, cards.ELEMENTS.tool(meta.i), el)
1462
- // Phone push — Claude is blocked until the user approves/denies.
1463
- // Set summary to "🔐 等审批: <tool>(<input summary>)" so the lock-
1464
- // screen notification shows which tool needs approval.
1465
- if (turn.userOpenId && turn.messageId) {
1466
- const inputSummary = cards.summarizeToolInput(meta.name, meta.input)
1467
- const tail = inputSummary && inputSummary.length > 30
1468
- ? inputSummary.slice(0, 30) + '…'
1469
- : inputSummary
1470
- const summary = tail
1471
- ? `🔐 等审批: ${meta.name} · ${tail}`
1472
- : `🔐 等审批: ${meta.name}`
1473
- void (async () => {
1474
- await this.setUrgentSummary(turn.cardId, summary)
1475
- await feishu.urgentApp(turn.messageId, [turn.userOpenId])
1476
- })()
1477
- }
1478
- }
1479
-
1480
- private async closeTurnCard(suffix?: string): Promise<void> {
1013
+ private async closeTurnCard(suffix?: string, opts: { forcePush?: boolean } = {}): Promise<void> {
1481
1014
  // CRITICAL: capture-and-null in a single synchronous block at entry
1482
1015
  // so a parallel `closeTurnCard` (e.g. result event firing while
1483
1016
  // onUserMessage is awaiting an interrupt) can't double-process the
@@ -1531,7 +1064,7 @@ export class Session {
1531
1064
  if (!suffix) {
1532
1065
  const ctxTokens = this.currentContextTokens()
1533
1066
  const ctxMax = this.contextWindowMax()
1534
- if (ctxTokens > 0 && ctxMax > 0) {
1067
+ if (ctxTokens > 0 && ctxMax !== null && ctxMax > 0) {
1535
1068
  const pct = Math.round((ctxTokens / ctxMax) * 100)
1536
1069
  metrics += ` · 📊 ${pct}%`
1537
1070
  }
@@ -1557,9 +1090,12 @@ export class Session {
1557
1090
  // completion), when we don't know who to ping, and when the turn
1558
1091
  // wasn't kicked off by the user typing a message — scheduled /
1559
1092
  // cron / loop wakeups finish on their own and shouldn't ping the
1560
- // phone. Fire-and-forget; urgent_app failures are non-fatal and
1561
- // already logged in feishu.ts.
1562
- if (!suffix && turn.trigger === 'user_message' && turn.userOpenId && turn.messageId) {
1093
+ // phone. `opts.forcePush` overrides the suffix-gate for the
1094
+ // "consecutive SDK errors, giving up" case — that close has a non-
1095
+ // empty suffix but the user still needs to know we bailed.
1096
+ // Fire-and-forget; urgent_app failures are non-fatal and already
1097
+ // logged in feishu.ts.
1098
+ if ((opts.forcePush || !suffix) && turn.trigger === 'user_message' && turn.userOpenId && turn.messageId) {
1563
1099
  void feishu.urgentApp(turn.messageId, [turn.userOpenId])
1564
1100
  }
1565
1101
 
@@ -1594,13 +1130,8 @@ export class Session {
1594
1130
 
1595
1131
  // Fire uploads sequentially AFTER the card is sealed so each file
1596
1132
  // posts as its own Feishu message below the conversation card.
1597
- // Path gate: workDir (Claude's project sandbox), the inbox where
1598
- // user-uploaded attachments land, and the /tmp/lodestar- namespace
1599
- // for ad-hoc artifacts. Anything outside is refused — see
1600
- // feishu.isPathAllowed.
1601
- const allowedRoots = [this.workDir, INBOX_DIR, '/tmp/lodestar-']
1602
1133
  for (const p of sendPaths) {
1603
- await feishu.uploadAndSend(this.chatId, p, allowedRoots)
1134
+ await feishu.uploadAndSend(this.chatId, p)
1604
1135
  }
1605
1136
  }
1606
1137
  }