@leviyuan/lodestar 0.2.7 → 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,22 +42,68 @@ 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
- * batch-merges every mid-turn user message into a single combined turn
122
- * once the in-flight turn finishes, so the daemon only ever observes
123
- * **one** init event per batch (no matter how many Feishu messages went
124
- * into the batch). Tracking a count + last-sender (rather than an
125
- * Array<msg>) keeps the daemon's view in sync with the SDK's actual
126
- * dequeue semantics. Empirically verified 2026-05-15 from the SDK's
127
- * `queue-operation` transcript events: 4 enqueues during a long turn
128
- * single dequeue at turn end one merged user message. Count is
129
- * decremented to 0 wholesale at the `init` boundary because the SDK
130
- * has already collapsed them into one turn. Distinguishes user-msg
131
- * turns from cron-fired scheduled wakeups: count > 0 user;
132
- * count === 0 scheduled (and `initCount > 1`). */
88
+ * USUALLY batch-merges every mid-turn user message into a single
89
+ * combined turn once the in-flight turn finishes, so most of the time
90
+ * the daemon observes **one** init event per batch. Tracking a count +
91
+ * last-sender (rather than an Array<msg>) keeps the daemon's view
92
+ * loosely in sync with the SDK's dequeue semantics. Caveat verified
93
+ * 2026-05-17 (test1 accumulator, 8-message rapid-fire): when the first
94
+ * write lands in an idle SDK, that single msg gets its own turn and
95
+ * the rest merge into a second turn i.e. 1+(N-1) split, not always
96
+ * one merge. To stay coherent with this, `drainMidTurnAndOpen` bumps
97
+ * the count by `batch.length` up front (covering both the first solo
98
+ * turn and the eventual merged tail), and the init handler resets to
99
+ * 0 on the first claim. If the SDK takes the merge path, the bail at
100
+ * `currentTurn=yes` in the init handler leaves pendingCount stale (>0)
101
+ * until the GC at the next `onUserMessage`; if it takes the split
102
+ * path, the second init sees pendingCount>0 and correctly classifies
103
+ * the trailing batch as user-batch (not a scheduled wakeup).
104
+ * Distinguishes user-msg turns from cron-fired scheduled
105
+ * wakeups: count > 0 ⇒ user; count === 0 ⇒ scheduled (and
106
+ * `initCount > 1`). */
133
107
  private pendingUserMessageCount = 0
134
108
  /** Mid-turn user messages buffered DAEMON-SIDE (not yet sendUserText'd
135
109
  * to the SDK). Drained in the `result` handler by writing each to SDK
@@ -179,35 +153,15 @@ export class Session {
179
153
  * message. The flag tells the init handler "an eager open is already
180
154
  * claiming the slot, stand down". */
181
155
  private openingTurn = false
182
- private pendingPermissions = new Map<string, { toolUseId: string }>()
183
- /** Open AskUserQuestion tool calls — keyed by tool_use_id. The SDK
184
- * routes AskUserQuestion through the can_use_tool flow even under
185
- * bypass; we have to thread the permission `requestId` through here
186
- * so the answer (option click OR custom text submit) can resolve
187
- * the permission with `updatedInput.answers` populated.
188
- * `deferredAnswer` covers the race where the user clicks/submits
189
- * BEFORE can_use_tool arrives (addTool fires on the assistant
190
- * message; can_use_tool is a separate control_request that lands
191
- * slightly later). */
192
- private pendingAsks = new Map<string, {
193
- questions: cards.AskQuestion[]
194
- i: number
195
- requestId?: string
196
- /** 累计答案 — key 是 question 原文 (SDK 把这条 record 格式
197
- * 化进 tool_result), value 是用户选的 option label 或自定
198
- * 义文字。全部 question 都答完时一并塞进 updatedInput.answers
199
- * 发回 SDK。 */
200
- answers: Record<string, string>
201
- /** 已答详情 (按 question idx 索引),用来给历史面板和 terminal
202
- * 状态画选中态。answers 同步累计,但这里多保留 customText /
203
- * optionIdx 字段以便 UI 区分两种回答路径。 */
204
- answered: Map<number, cards.AskAnswered>
205
- /** 当前展示的 question idx。undefined 表示全部答完 (terminal)
206
- * —— 这时若 requestId 已就位则 finalize;否则等 renderPermission
207
- * 一来立即 finalize。 */
208
- currentIdx?: number
209
- }>()
210
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
211
165
  // Last seen sessionId — preserved across `kill`/`stop` so a later
212
166
  // `restart` can resume the same Claude conversation even after the
213
167
  // child process is gone.
@@ -215,14 +169,6 @@ export class Session {
215
169
  private startedAt: number = 0
216
170
  private cumStats: CumStats = { tokens: 0, costUsd: 0, turns: 0 }
217
171
  private lastTurnDelta: LastTurnDelta | null = null
218
- /** Local mirror of the SDK's task list — built incrementally from
219
- * TaskCreate / TaskUpdate input+output pairs and rendered as a footer
220
- * on every Task* panel. Lives for the lifetime of the Session
221
- * instance; daemon restart wipes it (the SDK doesn't replay history).
222
- * Not authoritative — Claude calling TaskList is still the source of
223
- * truth; this mirror is purely for the panel readout. */
224
- private currentTodos = new Map<number, cards.Todo>()
225
- status: Status = 'stopped'
226
172
 
227
173
  constructor(
228
174
  public readonly sessionName: string,
@@ -239,19 +185,6 @@ export class Session {
239
185
  }
240
186
  }
241
187
 
242
- /** Patch the card-level summary (the text Feishu uses for chat-list
243
- * preview AND lock-screen push), then return when the API call has
244
- * landed. Used right before urgent_app so the push notification's
245
- * derived preview describes the *action that needs attention* (an
246
- * unanswered question, a pending permission ask) rather than the
247
- * stale assistant-text tail that patchSummaryThrottled was streaming.
248
- * cancelSummary kills any in-flight throttled write so our explicit
249
- * patch isn't immediately clobbered. */
250
- private async setUrgentSummary(cardId: string, content: string): Promise<void> {
251
- cardkit.cancelSummary(cardId)
252
- await cardkit.patchSettings(cardId, { config: { summary: { content } } })
253
- }
254
-
255
188
  /** Minimal cross-chat snapshot for the `hi` peer-list section.
256
189
  * `startedAt` stays private so this is the documented read path. */
257
190
  peerSnapshot(): { name: string; status: Status; uptimeMs?: number } {
@@ -290,6 +223,23 @@ export class Session {
290
223
  })
291
224
  this.wireProc(this.proc)
292
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
+ })
293
243
 
294
244
  await feishu.sendText(this.chatId, `✅ Lodestar session "${this.sessionName}" 已就绪,发消息开始对话。`)
295
245
  this.status = 'idle'
@@ -343,6 +293,7 @@ export class Session {
343
293
  this.initCount = 0
344
294
  this.openingTurn = false
345
295
  this.pendingPermissions.clear()
296
+ this.consecutiveErrors = 0
346
297
  this.status = 'stopped'
347
298
  await proc.kill()
348
299
  await feishu.sendText(this.chatId, `🔴 ${reason} (session: ${this.sessionName})`)
@@ -363,6 +314,7 @@ export class Session {
363
314
  this.initCount = 0
364
315
  this.openingTurn = false
365
316
  this.pendingPermissions.clear()
317
+ this.consecutiveErrors = 0
366
318
  if (resume && prevSessionId) {
367
319
  this.proc = new ClaudeProcess({
368
320
  workDir: this.workDir,
@@ -485,6 +437,10 @@ export class Session {
485
437
  // reads better than `claude-opus-4-7` in the small status header.
486
438
  const rawModel = this.proc?.lastModel ?? null
487
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()
488
444
  const card = cards.consoleCard({
489
445
  sessionName: this.sessionName,
490
446
  status: this.status,
@@ -510,6 +466,7 @@ export class Session {
510
466
  }
511
467
  : undefined,
512
468
  sessionId: this.proc?.sessionId ?? this.lastSessionId,
469
+ sysinfo,
513
470
  })
514
471
  const messageId = await feishu.sendCard(this.chatId, card)
515
472
  if (!messageId) return
@@ -675,191 +632,28 @@ export class Session {
675
632
  }
676
633
  }
677
634
 
678
- async onPermissionDecision(
679
- requestId: string,
680
- decision: 'allow' | 'allow_always' | 'deny',
681
- user: string,
682
- ): Promise<void> {
683
- const pending = this.pendingPermissions.get(requestId)
684
- if (!pending) { log(`session "${this.sessionName}": stray permission ${requestId}`); return }
685
- this.pendingPermissions.delete(requestId)
686
-
687
- // Update the tool element in the main turn card in place — the
688
- // permission decision lives on the same row as the tool call.
689
- const turn = this.currentTurn
690
- const meta = turn?.toolByUseId.get(pending.toolUseId)
691
- if (turn && meta) {
692
- const todos = this.isTaskWorkflow(meta.name) ? this.todosArray() : undefined
693
- if (decision === 'deny') {
694
- const el = cards.toolCallElement(meta.i, meta.name, meta.input, `🚫 已拒绝 by ${user || '匿名'}`, '❌', undefined, todos)
695
- void cardkit.replaceElement(turn.cardId, cards.ELEMENTS.tool(meta.i), el)
696
- } else {
697
- const label = decision === 'allow_always' ? '始终允许' : '已允许'
698
- meta.resolvedNote = `✅ **${label}** by ${user || '匿名'}`
699
- const el = cards.toolCallElement(meta.i, meta.name, meta.input, null, '⏳', meta.resolvedNote, todos)
700
- void cardkit.replaceElement(turn.cardId, cards.ELEMENTS.tool(meta.i), el)
701
- }
702
- }
703
-
704
- const claudeDecision = decision === 'deny' ? 'deny' : 'allow'
705
- this.proc?.sendPermissionResponse(requestId, claudeDecision)
706
-
707
- if (decision === 'allow_always') {
708
- this.proc?.sendSetPermissionMode('acceptEdits')
709
- }
710
-
711
- if (this.pendingPermissions.size === 0 && this.status === 'awaiting_permission') {
712
- this.status = 'working'
713
- }
714
- }
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.
715
638
 
716
- /** True iff there's at least one open AskUserQuestion awaiting an
717
- * answer in this session. `daemon.handleMessage` uses this to
718
- * decide whether an inbound chat message should be a custom answer
719
- * (routed to onAskMessageAnswer) instead of opening a new turn. */
720
639
  hasPendingAsk(): boolean {
721
- return this.pendingAsks.size > 0
640
+ return sessionAsk.hasPendingAsk(this)
722
641
  }
723
642
 
724
- /** True iff a turn is currently running (or a queued user message is
725
- * waiting for its turn to start). daemon uses this to drop a hourglass
726
- * reaction on inbound messages — without it the user sees no visible
727
- * acknowledgement that their type-ahead message landed (the card
728
- * doesn't open until the current turn finishes). */
729
- isBusy(): boolean {
730
- 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)
731
645
  }
732
646
 
733
- /** Funnel an arbitrary chat message into the *current* question
734
- * of the oldest pending ask as a `customText` answer. Multi-
735
- * question semantics: from the user's perspective, the chat
736
- * input always answers whatever question is on screen right now
737
- * (`pending.currentIdx`), and a new question slides in after. */
738
- async onAskMessageAnswer(text: string, user: string): Promise<void> {
739
- const firstEntry = this.pendingAsks.entries().next()
740
- if (firstEntry.done) {
741
- log(`session "${this.sessionName}": onAskMessageAnswer with no pending — falling back to onUserMessage`)
742
- await this.onUserMessage(text)
743
- return
744
- }
745
- const [toolUseId, pending] = firstEntry.value
746
- if (pending.currentIdx === undefined) {
747
- log(`session "${this.sessionName}": pending ask ${toolUseId} already terminal — ignoring message`)
748
- return
749
- }
750
- await this.onAskCustomAnswer(toolUseId, pending.currentIdx, text, user)
751
- }
752
-
753
- /** Click handler for an option button. The click must target the
754
- * question currently on screen (`pending.currentIdx`); a stale
755
- * click (e.g. user clicked an older render before it swapped in
756
- * the next question) is logged and dropped — better than double-
757
- * answering. */
758
- async onAskAnswer(toolUseId: string, questionIdx: number, optionIdx: number, user: string): Promise<void> {
759
- const pending = this.pendingAsks.get(toolUseId)
760
- if (!pending) { log(`session "${this.sessionName}": stray ask answer for ${toolUseId}`); return }
761
- if (questionIdx !== pending.currentIdx) {
762
- log(`session "${this.sessionName}": stale ask click q=${questionIdx} current=${pending.currentIdx}`)
763
- return
764
- }
765
- 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)
766
649
  }
767
650
 
768
- /** Custom-text branch. Same staleness rule as onAskAnswer; empty
769
- * input is silently ignored (panel stays pending). */
770
- async onAskCustomAnswer(toolUseId: string, questionIdx: number, customText: string, user: string): Promise<void> {
771
- const pending = this.pendingAsks.get(toolUseId)
772
- if (!pending) { log(`session "${this.sessionName}": stray ask custom for ${toolUseId}`); return }
773
- const trimmed = (customText ?? '').trim()
774
- if (!trimmed) { log(`session "${this.sessionName}": empty custom answer, ignoring`); return }
775
- if (questionIdx !== pending.currentIdx) {
776
- log(`session "${this.sessionName}": stale ask custom q=${questionIdx} current=${pending.currentIdx}`)
777
- return
778
- }
779
- this.advanceAsk(toolUseId, { customText: trimmed, user })
780
- }
781
-
782
- /** Record an answer for the current question, advance the state
783
- * machine, repaint. If every question is now answered, finalize
784
- * (or defer the finalize until can_use_tool lands — the race is
785
- * handled by renderPermission). */
786
- private advanceAsk(
787
- toolUseId: string,
788
- answer: { optionIdx?: number; customText?: string; user: string },
789
- ): void {
790
- const pending = this.pendingAsks.get(toolUseId)
791
- if (!pending || pending.currentIdx === undefined) return
792
- const cur = pending.currentIdx
793
- const q = pending.questions[cur]
794
- if (!q) { log(`session "${this.sessionName}": advanceAsk currentIdx=${cur} out of range`); return }
795
- // Resolve the literal answer value — custom text wins if both set.
796
- let value: string
797
- if (answer.customText !== undefined) {
798
- value = answer.customText
799
- } else if (answer.optionIdx !== undefined) {
800
- const opt = q.options?.[answer.optionIdx]
801
- if (!opt) { log(`session "${this.sessionName}": advanceAsk option ${answer.optionIdx} out of range`); return }
802
- value = opt.label
803
- } else {
804
- log(`session "${this.sessionName}": advanceAsk with neither customText nor optionIdx`)
805
- return
806
- }
807
- pending.answers[q.question] = value
808
- pending.answered.set(cur, {
809
- optionIdx: answer.optionIdx,
810
- customText: answer.customText,
811
- user: answer.user,
812
- })
813
- // Next unanswered idx — linear from cur+1. Implementation
814
- // always moves forward; we don't currently let users revisit a
815
- // previous question (would need richer UI affordance for that).
816
- const total = pending.questions.length
817
- let nextIdx: number | undefined = undefined
818
- for (let i = cur + 1; i < total; i++) {
819
- if (!pending.answered.has(i)) { nextIdx = i; break }
820
- }
821
- pending.currentIdx = nextIdx
822
-
823
- const turn = this.currentTurn
824
- const meta = turn?.toolByUseId.get(toolUseId)
825
- if (turn && meta) {
826
- const el = cards.askUserQuestionElement(
827
- meta.i, toolUseId, pending.questions,
828
- nextIdx === undefined ? '✅' : '🤔',
829
- { currentIdx: nextIdx, answered: pending.answered },
830
- )
831
- void cardkit.replaceElement(turn.cardId, cards.ELEMENTS.tool(meta.i), el)
832
- }
833
-
834
- if (nextIdx === undefined) {
835
- // All done. Finalize iff we have the permission request id;
836
- // otherwise renderPermission will pick it up when it arrives.
837
- if (pending.requestId) this.finalizeAsk(toolUseId)
838
- else log(`session "${this.sessionName}": ask ${toolUseId} all answered, waiting for can_use_tool`)
839
- }
651
+ onAskCustomAnswer(toolUseId: string, questionIdx: number, customText: string, user: string): Promise<void> {
652
+ return sessionAsk.onAskCustomAnswer(this, toolUseId, questionIdx, customText, user)
840
653
  }
841
654
 
842
- /** Settle a fully-answered AskUserQuestion: emit the SDK allow
843
- * with the full `answers` record folded into `updatedInput`,
844
- * drop bookkeeping, restore status. The terminal panel paint was
845
- * already done by the final advanceAsk; this is just protocol. */
846
- private finalizeAsk(toolUseId: string): void {
847
- const pending = this.pendingAsks.get(toolUseId)
848
- if (!pending || !pending.requestId) return
849
- const meta = this.currentTurn?.toolByUseId.get(toolUseId)
850
- const originalInput = meta?.input ?? {}
851
- this.proc?.sendPermissionResponse(pending.requestId, 'allow', {
852
- updatedInput: { ...originalInput, answers: pending.answers },
853
- })
854
- this.pendingPermissions.delete(pending.requestId)
855
- if (meta) {
856
- meta.output = JSON.stringify({ answers: pending.answers })
857
- meta.isError = false
858
- }
859
- this.pendingAsks.delete(toolUseId)
860
- if (this.pendingPermissions.size === 0 && this.status === 'awaiting_permission') {
861
- this.status = 'working'
862
- }
655
+ onPermissionDecision(requestId: string, decision: 'allow' | 'allow_always' | 'deny', user: string): Promise<void> {
656
+ return sessionPermission.onPermissionDecision(this, requestId, decision, user)
863
657
  }
864
658
 
865
659
  // ── Wiring Claude → Feishu ─────────────────────────────────────────
@@ -932,13 +726,13 @@ export class Session {
932
726
  this.appendThinking(text)
933
727
  })
934
728
  p.on('tool_use', ({ id, name, input }: { id: string; name: string; input: any }) => {
935
- this.addTool(id, name, input)
729
+ sessionTools.addTool(this, id, name, input)
936
730
  })
937
731
  p.on('tool_result', ({ tool_use_id, content, is_error }: any) => {
938
- this.completeTool(tool_use_id, content, is_error)
732
+ sessionTools.completeTool(this, tool_use_id, content, is_error)
939
733
  })
940
734
  p.on('can_use_tool', (req: CanUseToolRequest) => {
941
- this.renderPermission(req)
735
+ sessionPermission.renderPermission(this, req)
942
736
  })
943
737
  p.on('hook_callback', (req: HookCallbackRequest) => {
944
738
  // No hooks registered → fail-safe ack.
@@ -946,21 +740,65 @@ export class Session {
946
740
  })
947
741
  p.on('result', () => {
948
742
  this.accumulateResultStats()
949
- // Daemon-driven rotation: mid-turn msgs were buffered (not yet
950
- // sent to SDK) — close the in-flight card with `📨 转交新卡` and
951
- // drain the buffer in one shot. The drain writes each buffered
952
- // msg to SDK stdin, which is the `priority="now"` wake the SDK
953
- // polling loop needs (claude-code issue #39632) AND constitutes
954
- // the input for the new batch turn. We open the new card here
955
- // ourselves rather than waiting on init — the SDK init for this
956
- // batch will fire shortly but `currentTurn` will already be set,
957
- // 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).
958
758
  const hasMidTurn = this.pendingMidTurnMsgs.length > 0
959
- const suffix = hasMidTurn ? '📨 转交新卡' : undefined
960
- log(`session "${this.sessionName}": SDK result midBuffer=${this.pendingMidTurnMsgs.length} suffix=${suffix ?? '<✅>'}`)
961
- 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 })
962
787
  this.status = 'idle'
963
- 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
+ }
964
802
  })
965
803
  p.on('exit', ({ code, signal, expected }: any) => {
966
804
  log(`session "${this.sessionName}": claude exited code=${code} signal=${signal} expected=${expected}`)
@@ -972,6 +810,7 @@ export class Session {
972
810
  this.releaseAllReactions()
973
811
  this.initCount = 0
974
812
  this.openingTurn = false
813
+ this.consecutiveErrors = 0
975
814
  this.status = 'stopped'
976
815
  if (!expected && code !== 0 && signal !== 'SIGTERM') {
977
816
  void feishu.sendText(this.chatId, `⚠️ Claude 异常退出 (code=${code}, signal=${signal})。回复任意消息将重新启动。`)
@@ -1018,9 +857,11 @@ export class Session {
1018
857
  * .contextWindow` captured by ClaudeProcess on each turn close, so
1019
858
  * the daemon doesn't have to enumerate model ids itself (was the
1020
859
  * source of a "560K/200K" display bug — model id didn't include
1021
- * `[1m]` so the hardcoded fallback won). */
1022
- private contextWindowMax(): number {
1023
- 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
1024
865
  }
1025
866
 
1026
867
  /** Drain `pendingMidTurnMsgs` to the SDK and open a fresh card for the
@@ -1029,7 +870,17 @@ export class Session {
1029
870
  * calls wake the SDK polling loop (priority="now" semantics) and
1030
871
  * comprise the input for the new turn. Opens the card here rather
1031
872
  * than deferring to init because the init for this batch will arrive
1032
- * with `currentTurn` already set and bail. */
873
+ * with `currentTurn` already set and bail.
874
+ *
875
+ * Each sendUserText also bumps `pendingUserMessageCount`. The SDK
876
+ * USUALLY collapses our N writes into one merged turn, but **not
877
+ * always** — empirically observed 2026-05-17, test1 accumulator
878
+ * session: when the first write lands in an idle SDK (turn just
879
+ * ended), the SDK eagerly starts a turn for that msg alone, then
880
+ * merges the rest into a second turn. Without the bump here, that
881
+ * second turn fires an `init` with `pendingUserMessageCount === 0`
882
+ * and the init handler misclassifies it as a scheduled wakeup,
883
+ * painting the `⏰ 触发` banner on what is really a user batch. */
1033
884
  private async drainMidTurnAndOpen(): Promise<void> {
1034
885
  if (this.pendingMidTurnMsgs.length === 0) return
1035
886
  const batch = this.pendingMidTurnMsgs
@@ -1038,6 +889,7 @@ export class Session {
1038
889
  try {
1039
890
  for (const msg of batch) {
1040
891
  this.proc!.sendUserText(msg.wireText, msg.files)
892
+ this.pendingUserMessageCount++
1041
893
  if (msg.msgId) {
1042
894
  const rid = this.pendingReactionIds.get(msg.msgId) ?? ''
1043
895
  this.currentBatchReactionIds.set(msg.msgId, rid)
@@ -1158,308 +1010,7 @@ export class Session {
1158
1010
  )
1159
1011
  }
1160
1012
 
1161
- private isTaskWorkflow(name: string): boolean {
1162
- return name.startsWith('Task') && name !== 'Task'
1163
- }
1164
-
1165
- private todosArray(): cards.Todo[] {
1166
- return [...this.currentTodos.values()]
1167
- }
1168
-
1169
- private addTool(toolUseId: string, name: string, input: any): void {
1170
- if (!this.currentTurn) return
1171
- // Close current assistant segment (if any) so the tool panel renders
1172
- // AFTER it in card body order. Flush queues the segment's last
1173
- // buffered delta before the tool element is inserted.
1174
- if (this.currentTurn.currentAssistantSegmentId) {
1175
- void cardkit.flush(this.currentTurn.cardId)
1176
- this.currentTurn.currentAssistantSegmentId = null
1177
- this.currentTurn.currentAssistantText = ''
1178
- }
1179
- // Consecutive Read merger: if a Read run is already open, append to
1180
- // its batch and re-render the panel instead of inserting a new one.
1181
- // Any other tool name closes the run (handled below).
1182
- if (name === 'Read' && this.currentTurn.openReadBatchI !== null) {
1183
- const batchI = this.currentTurn.openReadBatchI
1184
- const batch = this.currentTurn.readBatches.get(batchI)!
1185
- const slot = batch.items.length
1186
- batch.items.push({ toolUseId, input, output: null, isError: false })
1187
- this.currentTurn.toolByUseId.set(toolUseId, { i: batchI, name, input, readBatchSlot: slot })
1188
- const el = cards.readBatchElement(batchI, batch.items)
1189
- void cardkit.replaceElement(this.currentTurn.cardId, cards.ELEMENTS.tool(batchI), el)
1190
- return
1191
- }
1192
- if (name !== 'Read') this.currentTurn.openReadBatchI = null
1193
- const i = this.currentTurn.toolCount++
1194
- if (name === 'Read') {
1195
- // First Read of a potential run — render the existing single-tool
1196
- // panel (which keeps the full file-contents dump on completion). If
1197
- // a second Read arrives, completeTool/addTool will switch it to
1198
- // `readBatchElement`.
1199
- this.currentTurn.openReadBatchI = i
1200
- this.currentTurn.readBatches.set(i, {
1201
- items: [{ toolUseId, input, output: null, isError: false }],
1202
- })
1203
- this.currentTurn.toolByUseId.set(toolUseId, { i, name, input, readBatchSlot: 0 })
1204
- const el = cards.toolCallElement(i, name, input, null, '⏳', undefined, undefined)
1205
- void cardkit.addElement(this.currentTurn.cardId, el, {
1206
- type: 'insert_before', targetElementId: cards.ELEMENTS.footer,
1207
- })
1208
- return
1209
- }
1210
- this.currentTurn.toolByUseId.set(toolUseId, { i, name, input })
1211
- // AskUserQuestion is a client-side tool — daemon renders the choice
1212
- // UI in-line and supplies the tool_result itself once the user
1213
- // clicks. Branch BEFORE the generic toolCallElement so we never
1214
- // fall through to a JSON dump or, worse, get clobbered by the
1215
- // permission flow (which would render 🔐 three-button buttons that
1216
- // don't match the actual N options).
1217
- if (name === 'AskUserQuestion') {
1218
- const questions = Array.isArray(input?.questions) ? input.questions as cards.AskQuestion[] : []
1219
- const startIdx = questions.length > 0 ? 0 : undefined
1220
- const answered = new Map<number, cards.AskAnswered>()
1221
- this.pendingAsks.set(toolUseId, {
1222
- questions,
1223
- i,
1224
- answers: {},
1225
- answered,
1226
- currentIdx: startIdx,
1227
- })
1228
- const el = cards.askUserQuestionElement(i, toolUseId, questions, '🤔', {
1229
- currentIdx: startIdx,
1230
- answered,
1231
- })
1232
- void cardkit.addElement(this.currentTurn.cardId, el, {
1233
- type: 'insert_before',
1234
- targetElementId: cards.ELEMENTS.footer,
1235
- })
1236
- // Phone push — user has to come back and answer before Claude can
1237
- // continue. Set summary to the question text so the lock-screen
1238
- // notification preview shows what the user needs to answer.
1239
- if (this.currentTurn.userOpenId && this.currentTurn.messageId) {
1240
- const turn = this.currentTurn
1241
- const q0 = questions[0]?.question?.trim() ?? ''
1242
- const truncated = q0.length > 40 ? q0.slice(0, 40) + '…' : q0
1243
- const summary = questions.length > 1
1244
- ? `❓ 待回答 ${questions.length} 题${truncated ? `: ${truncated}` : ''}`
1245
- : truncated
1246
- ? `❓ ${truncated}`
1247
- : '❓ 等你回答问题'
1248
- void (async () => {
1249
- await this.setUrgentSummary(turn.cardId, summary)
1250
- await feishu.urgentApp(turn.messageId, [turn.userOpenId])
1251
- })()
1252
- }
1253
- return
1254
- }
1255
- // Pending Task* panels still show the *pre-op* todo mirror so users
1256
- // can read the current state immediately, without waiting for the
1257
- // tool to return.
1258
- const todos = this.isTaskWorkflow(name) ? this.todosArray() : undefined
1259
- const el = cards.toolCallElement(i, name, input, null, '⏳', undefined, todos)
1260
- void cardkit.addElement(this.currentTurn.cardId, el, {
1261
- type: 'insert_before',
1262
- targetElementId: cards.ELEMENTS.footer,
1263
- })
1264
- }
1265
-
1266
- private completeTool(toolUseId: string, content: any, isError: boolean): void {
1267
- if (!this.currentTurn) return
1268
- const meta = this.currentTurn.toolByUseId.get(toolUseId)
1269
- if (!meta) return
1270
- const output = typeof content === 'string'
1271
- ? content
1272
- : Array.isArray(content)
1273
- ? content.map((c: any) => c?.text ?? JSON.stringify(c)).join('\n')
1274
- : JSON.stringify(content)
1275
- // Stash on the meta — every Task* op coming after this point may
1276
- // need to re-render this panel with a fresher todo footer, so we
1277
- // can't discard the output after the first paint.
1278
- meta.output = output
1279
- meta.isError = isError
1280
- // AskUserQuestion already had its final panel painted by resolveAsk
1281
- // (✅ + the chosen option marked, others dimmed). The tool_result
1282
- // arriving here is just the SDK's synthesised echo — re-rendering
1283
- // via toolCallElement would clobber the nice option-row layout
1284
- // with a generic JSON dump. Bail out; the panel is done.
1285
- if (meta.name === 'AskUserQuestion') return
1286
- // Read batch path: update this row's status in the shared batch then
1287
- // re-render. Single-item batches keep the original full-output panel
1288
- // (file-contents dump); 2+ items switch to the compact `Read · N 次`
1289
- // listing, which overwrites whatever was last drawn at this i.
1290
- if (meta.name === 'Read' && meta.readBatchSlot != null) {
1291
- const batch = this.currentTurn.readBatches.get(meta.i)
1292
- if (batch) {
1293
- const row = batch.items[meta.readBatchSlot]
1294
- if (row) { row.output = output; row.isError = isError }
1295
- const el = batch.items.length >= 2
1296
- ? cards.readBatchElement(meta.i, batch.items)
1297
- : cards.toolCallElement(meta.i, meta.name, meta.input, output, isError ? '❌' : '✅', meta.resolvedNote, undefined)
1298
- void cardkit.replaceElement(this.currentTurn.cardId, cards.ELEMENTS.tool(meta.i), el)
1299
- }
1300
- return
1301
- }
1302
- // Update the local todo mirror BEFORE rendering so the just-
1303
- // completed panel shows the new state too (e.g. a TaskCreate panel
1304
- // already lists the task it just created).
1305
- if (!isError && this.isTaskWorkflow(meta.name)) {
1306
- this.updateTodosFromTask(meta.name, meta.input, output)
1307
- }
1308
- const todos = this.isTaskWorkflow(meta.name) ? this.todosArray() : undefined
1309
- const el = cards.toolCallElement(meta.i, meta.name, meta.input, output, isError ? '❌' : '✅', meta.resolvedNote, todos)
1310
- void cardkit.replaceElement(this.currentTurn.cardId, cards.ELEMENTS.tool(meta.i), el)
1311
- // Cascade the new mirror into every prior Task* panel in this turn
1312
- // so any expanded panel reflects the latest state, not the snapshot
1313
- // captured when that op ran.
1314
- if (!isError && this.isTaskWorkflow(meta.name)) {
1315
- this.refreshOtherTaskPanels(toolUseId)
1316
- }
1317
- }
1318
-
1319
- /** Roll a single Task* op into the local mirror — best-effort. Output
1320
- * parsing is regex-based (the SDK returns plain text like "Task #7
1321
- * created successfully: …"), so unexpected variants are skipped
1322
- * silently rather than blowing up the panel render. */
1323
- private updateTodosFromTask(name: string, input: any, output: string): void {
1324
- switch (name) {
1325
- case 'TaskCreate': {
1326
- const m = output.match(/Task #(\d+) created/)
1327
- if (!m) return
1328
- const id = Number(m[1])
1329
- this.currentTodos.set(id, {
1330
- id,
1331
- subject: input.subject,
1332
- description: input.description,
1333
- activeForm: input.activeForm,
1334
- status: 'pending',
1335
- })
1336
- return
1337
- }
1338
- case 'TaskUpdate': {
1339
- const id = Number(input.taskId)
1340
- if (!Number.isFinite(id)) return
1341
- // status=deleted is the SDK's tombstone — drop from the mirror
1342
- // so the readout doesn't carry it forever. Server still keeps
1343
- // it; the mirror is just for the panel footer.
1344
- if (input.status === 'deleted') { this.currentTodos.delete(id); return }
1345
- const cur = this.currentTodos.get(id) ?? { id, status: 'pending' as const }
1346
- if (input.status) cur.status = input.status
1347
- if (input.subject) cur.subject = input.subject
1348
- if (input.description) cur.description = input.description
1349
- if (input.owner) cur.owner = input.owner
1350
- if (input.activeForm) cur.activeForm = input.activeForm
1351
- this.currentTodos.set(id, cur)
1352
- return
1353
- }
1354
- // TaskList / TaskGet / TaskStop / TaskOutput / TaskDelete:
1355
- // read-only or parse-heavy — skip mirror update. The panel will
1356
- // still render the SDK's textual result below the operation
1357
- // block, which is enough to disambiguate.
1358
- }
1359
- }
1360
-
1361
- /** Re-render every Task* panel in the current turn (except the one
1362
- * that just landed — already up-to-date) so they all show the latest
1363
- * todo mirror in their footers. Cheap: ELEMENTS.tool(i) replace is
1364
- * queued through the per-card Promise chain like any other op. */
1365
- private refreshOtherTaskPanels(skipToolUseId: string): void {
1366
- if (!this.currentTurn) return
1367
- const todos = this.todosArray()
1368
- for (const [id, meta] of this.currentTurn.toolByUseId) {
1369
- if (id === skipToolUseId) continue
1370
- if (!this.isTaskWorkflow(meta.name)) continue
1371
- const status: '⏳' | '✅' | '❌' = meta.output === undefined
1372
- ? '⏳'
1373
- : (meta.isError ? '❌' : '✅')
1374
- const el = cards.toolCallElement(
1375
- meta.i, meta.name, meta.input, meta.output ?? null,
1376
- status, meta.resolvedNote, todos,
1377
- )
1378
- void cardkit.replaceElement(this.currentTurn.cardId, cards.ELEMENTS.tool(meta.i), el)
1379
- }
1380
- }
1381
-
1382
- /** Merge the permission ask into the existing tool element in the
1383
- * current turn card. The user sees one continuous timeline: ⏳ pending
1384
- * → 🔐 awaiting approval (with buttons) → ⏳ allowed / ❌ denied → ✅
1385
- * with output. No floating orange card.
1386
- *
1387
- * `tool_use` is emitted as part of the assistant message and lands on
1388
- * our `addTool` handler BEFORE the SDK's `can_use_tool` control_request
1389
- * arrives — so by the time we get here, `toolByUseId` already has the
1390
- * entry we need to replace.
1391
- *
1392
- * Edge cases (no current turn / missing tool_use_id / unknown id) are
1393
- * surfaced loudly and auto-denied. We don't fall back to a standalone
1394
- * card — per the project's no-fallbacks rule, hidden anomalies are
1395
- * worse than visible deny errors. */
1396
- private renderPermission(req: CanUseToolRequest): void {
1397
- const turn = this.currentTurn
1398
- if (!turn) {
1399
- log(`session "${this.sessionName}": can_use_tool with no current turn — auto-deny req=${req.request_id}`)
1400
- this.proc?.sendPermissionResponse(req.request_id, 'deny', { denyMessage: 'no active turn' })
1401
- return
1402
- }
1403
- const toolUseId = req.tool_use_id
1404
- if (!toolUseId) {
1405
- log(`session "${this.sessionName}": can_use_tool without tool_use_id — auto-deny req=${req.request_id}`)
1406
- this.proc?.sendPermissionResponse(req.request_id, 'deny', { denyMessage: 'no tool_use_id' })
1407
- return
1408
- }
1409
- const meta = turn.toolByUseId.get(toolUseId)
1410
- if (!meta) {
1411
- log(`session "${this.sessionName}": can_use_tool for unknown tool_use_id=${toolUseId} — auto-deny req=${req.request_id}`)
1412
- this.proc?.sendPermissionResponse(req.request_id, 'deny', { denyMessage: 'unknown tool_use_id' })
1413
- return
1414
- }
1415
- // AskUserQuestion: SDK routes it through can_use_tool even under
1416
- // bypass. The PAYLOAD of "user has answered" is the permission
1417
- // response itself — specifically `updatedInput.answers`. So we
1418
- // CANNOT auto-allow here (that's the v0.1.2 bug: SDK got an empty
1419
- // answers map and immediately synthesised a "User has answered
1420
- // your questions: ." tool_result). Park the requestId on the
1421
- // pendingAsk record and wait for the user to click an option;
1422
- // onAskAnswer will then send allow + updatedInput.answers in one
1423
- // shot. If the user already clicked between addTool and now —
1424
- // the deferredAnswer slot — settle immediately.
1425
- if (meta.name === 'AskUserQuestion') {
1426
- const ask = this.pendingAsks.get(toolUseId)
1427
- if (!ask) {
1428
- log(`session "${this.sessionName}": AskUserQuestion ${toolUseId} missing pendingAsk — deny`)
1429
- this.proc?.sendPermissionResponse(req.request_id, 'deny', { denyMessage: 'no pending ask' })
1430
- return
1431
- }
1432
- ask.requestId = req.request_id
1433
- this.pendingPermissions.set(req.request_id, { toolUseId })
1434
- // Fast-clicker race: the user may have answered every question
1435
- // while we were still waiting for can_use_tool to arrive. If so,
1436
- // advanceAsk parked the all-done state and we drain it now.
1437
- if (ask.currentIdx === undefined) this.finalizeAsk(toolUseId)
1438
- return
1439
- }
1440
- this.status = 'awaiting_permission'
1441
- this.pendingPermissions.set(req.request_id, { toolUseId })
1442
- const el = cards.toolCallPermissionElement(meta.i, meta.name, meta.input, req.request_id)
1443
- void cardkit.replaceElement(turn.cardId, cards.ELEMENTS.tool(meta.i), el)
1444
- // Phone push — Claude is blocked until the user approves/denies.
1445
- // Set summary to "🔐 等审批: <tool>(<input summary>)" so the lock-
1446
- // screen notification shows which tool needs approval.
1447
- if (turn.userOpenId && turn.messageId) {
1448
- const inputSummary = cards.summarizeToolInput(meta.name, meta.input)
1449
- const tail = inputSummary && inputSummary.length > 30
1450
- ? inputSummary.slice(0, 30) + '…'
1451
- : inputSummary
1452
- const summary = tail
1453
- ? `🔐 等审批: ${meta.name} · ${tail}`
1454
- : `🔐 等审批: ${meta.name}`
1455
- void (async () => {
1456
- await this.setUrgentSummary(turn.cardId, summary)
1457
- await feishu.urgentApp(turn.messageId, [turn.userOpenId])
1458
- })()
1459
- }
1460
- }
1461
-
1462
- private async closeTurnCard(suffix?: string): Promise<void> {
1013
+ private async closeTurnCard(suffix?: string, opts: { forcePush?: boolean } = {}): Promise<void> {
1463
1014
  // CRITICAL: capture-and-null in a single synchronous block at entry
1464
1015
  // so a parallel `closeTurnCard` (e.g. result event firing while
1465
1016
  // onUserMessage is awaiting an interrupt) can't double-process the
@@ -1513,7 +1064,7 @@ export class Session {
1513
1064
  if (!suffix) {
1514
1065
  const ctxTokens = this.currentContextTokens()
1515
1066
  const ctxMax = this.contextWindowMax()
1516
- if (ctxTokens > 0 && ctxMax > 0) {
1067
+ if (ctxTokens > 0 && ctxMax !== null && ctxMax > 0) {
1517
1068
  const pct = Math.round((ctxTokens / ctxMax) * 100)
1518
1069
  metrics += ` · 📊 ${pct}%`
1519
1070
  }
@@ -1539,9 +1090,12 @@ export class Session {
1539
1090
  // completion), when we don't know who to ping, and when the turn
1540
1091
  // wasn't kicked off by the user typing a message — scheduled /
1541
1092
  // cron / loop wakeups finish on their own and shouldn't ping the
1542
- // phone. Fire-and-forget; urgent_app failures are non-fatal and
1543
- // already logged in feishu.ts.
1544
- 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) {
1545
1099
  void feishu.urgentApp(turn.messageId, [turn.userOpenId])
1546
1100
  }
1547
1101
 
@@ -1576,13 +1130,8 @@ export class Session {
1576
1130
 
1577
1131
  // Fire uploads sequentially AFTER the card is sealed so each file
1578
1132
  // posts as its own Feishu message below the conversation card.
1579
- // Path gate: workDir (Claude's project sandbox), the inbox where
1580
- // user-uploaded attachments land, and the /tmp/lodestar- namespace
1581
- // for ad-hoc artifacts. Anything outside is refused — see
1582
- // feishu.isPathAllowed.
1583
- const allowedRoots = [this.workDir, INBOX_DIR, '/tmp/lodestar-']
1584
1133
  for (const p of sendPaths) {
1585
- await feishu.uploadAndSend(this.chatId, p, allowedRoots)
1134
+ await feishu.uploadAndSend(this.chatId, p)
1586
1135
  }
1587
1136
  }
1588
1137
  }