@leviyuan/lodestar 0.2.8 → 0.3.0

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 DELETED
@@ -1,1606 +0,0 @@
1
- /**
2
- * Session — 1 Feishu chat ↔ 1 Claude headless process ↔ 1 streaming card.
3
- *
4
- * Owns the ClaudeProcess lifecycle, the per-turn card state machine, and
5
- * the in-flight permission map. Wires Claude's stdout events into Card
6
- * Kit ops, and wires Feishu inbound (text + card-action callbacks) into
7
- * Claude's stdin.
8
- */
9
-
10
- import { existsSync } from 'node:fs'
11
- import { join } from 'node:path'
12
- import { ClaudeProcess, type CanUseToolRequest, type HookCallbackRequest, type ClaudeUsage } from './claude-process'
13
- import { CHANNEL_INSTRUCTIONS } from './instructions'
14
- import * as cardkit from './cardkit'
15
- import * as cards from './cards'
16
- import * as feishu from './feishu'
17
- import { log } from './log'
18
- import { INBOX_DIR } from './paths'
19
- import { readUsage } from './usage'
20
-
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
- }
76
-
77
- const SEND_MARKER_RE = /\[\[send:\s*([^\]\n]+?)\s*\]\]/g
78
-
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
- export class Session {
109
- /** Process-wide registry of every Session ever constructed in this daemon.
110
- * Used by the `hi` console panel to enumerate sibling sessions across
111
- * Feishu groups. Sessions are never removed (matches the daemon's
112
- * `sessions` map lifecycle — one Session per chat for the daemon's
113
- * lifetime). Callers should filter on `isRunning()` when they only
114
- * want currently-alive Claude processes. */
115
- static readonly all: Set<Session> = new Set()
116
-
117
- private proc: ClaudeProcess | null = null
118
- private currentTurn: TurnState | null = null
119
- /** Count of user messages we've written to Claude's stdin since the last
120
- * turn opened on our side. NOT a FIFO of individual messages — the SDK
121
- * USUALLY batch-merges every mid-turn user message into a single
122
- * combined turn once the in-flight turn finishes, so most of the time
123
- * the daemon observes **one** init event per batch. Tracking a count +
124
- * last-sender (rather than an Array<msg>) keeps the daemon's view
125
- * loosely in sync with the SDK's dequeue semantics. Caveat verified
126
- * 2026-05-17 (test1 accumulator, 8-message rapid-fire): when the first
127
- * write lands in an idle SDK, that single msg gets its own turn and
128
- * the rest merge into a second turn — i.e. 1+(N-1) split, not always
129
- * one merge. To stay coherent with this, `drainMidTurnAndOpen` bumps
130
- * the count by `batch.length` up front (covering both the first solo
131
- * turn and the eventual merged tail), and the init handler resets to
132
- * 0 on the first claim. If the SDK takes the merge path, the bail at
133
- * `currentTurn=yes` in the init handler leaves pendingCount stale (>0)
134
- * until the GC at the next `onUserMessage`; if it takes the split
135
- * path, the second init sees pendingCount>0 and correctly classifies
136
- * the trailing batch as user-batch (not a scheduled wakeup).
137
- * Distinguishes user-msg turns from cron-fired scheduled
138
- * wakeups: count > 0 ⇒ user; count === 0 ⇒ scheduled (and
139
- * `initCount > 1`). */
140
- private pendingUserMessageCount = 0
141
- /** Mid-turn user messages buffered DAEMON-SIDE (not yet sendUserText'd
142
- * to the SDK). Drained in the `result` handler by writing each to SDK
143
- * stdin, which doubles as the `priority="now"` wake signal the SDK
144
- * polling loop needs to start the next batch turn (the SDK won't
145
- * auto-dequeue queued type-ahead msgs after `result` — confirmed via
146
- * claude-code issue #39632). Buffering also keeps mid-turn msgs out
147
- * of any AskUserQuestion `QUEUE remove` storm, since they were never
148
- * in the SDK queue to begin with. */
149
- private pendingMidTurnMsgs: Array<{ wireText: string; files: string[]; userOpenId: string; msgId: string }> = []
150
- /** Most recent userOpenId seen via `onUserMessage`. Used only when a
151
- * merged batch fires its init event and the daemon needs *some* open_id
152
- * to scope the eventual `urgent_app` push — there's no obviously right
153
- * answer when N messages from possibly different users collapse into
154
- * one turn, and "the most recent sender" is a defensible default for
155
- * the single-user private-bot scenario this product targets. */
156
- private lastUserOpenId = ''
157
- /** Feishu message_ids of user messages that arrived while the daemon
158
- * was busy (turn in flight or mid-open), mapped to the `reaction_id`
159
- * of the `OneSecond` reaction placed at arrival. The reaction_id is
160
- * what `deleteReaction` needs to *remove* the OneSecond once the
161
- * message has been absorbed by the SDK (either system-reminder
162
- * injection mid-turn or a merged-batch dequeue on next turn).
163
- * User feedback (2026-05-15): replacing OneSecond with a second
164
- * CheckMark stacked two emojis on the same row; cleaner UX is
165
- * "queued → released" via removal, not "queued → done" via
166
- * stacking. */
167
- private pendingReactionIds = new Map<string, string>()
168
- /** Snapshot of `pendingReactionIds` taken when the init handler
169
- * claims a merged batch — these are the Feishu messages whose
170
- * OneSecond reactions are the currently-open turn's responsibility
171
- * to clear (via deleteReaction). Empty for eager-opened solo turns
172
- * and for scheduled wakeups (no user messages went into those). */
173
- private currentBatchReactionIds = new Map<string, string>()
174
- /** Count of `system/init` events seen this subprocess. The first one is
175
- * the boot init (claimed by whichever user message lands first); all
176
- * subsequent ones mark the start of an SDK-initiated turn (queued
177
- * user message draining or a CronCreate fire). Reset on stop/restart/exit
178
- * since `init` re-fires after every spawn. */
179
- private initCount = 0
180
- /** Sync guard set before any `await` in the eager-open path of
181
- * `onUserMessage`, cleared after `currentTurn` is set. Closes the race
182
- * where an SDK-emitted `init` event lands during the eager open's
183
- * Feishu API await — without this, the init handler would observe
184
- * `currentTurn === null && queue empty` (we've already shifted) and
185
- * incorrectly open a *second* scheduled card for the same user
186
- * message. The flag tells the init handler "an eager open is already
187
- * claiming the slot, stand down". */
188
- 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
- private turnCounter = 0
218
- // Last seen sessionId — preserved across `kill`/`stop` so a later
219
- // `restart` can resume the same Claude conversation even after the
220
- // child process is gone.
221
- private lastSessionId: string | null = null
222
- private startedAt: number = 0
223
- private cumStats: CumStats = { tokens: 0, costUsd: 0, turns: 0 }
224
- 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
-
234
- constructor(
235
- public readonly sessionName: string,
236
- public readonly chatId: string,
237
- private opts: SessionOpts = {},
238
- ) {
239
- Session.all.add(this)
240
- // Restore last-known claude session_id from disk so a daemon restart
241
- // (systemctl, crash, watchdog) doesn't strand the user with a fresh
242
- // conversation when they next type `restart`.
243
- this.lastSessionId = feishu.getSessionResume(sessionName)
244
- if (this.lastSessionId) {
245
- log(`session "${sessionName}": restored lastSessionId=${this.lastSessionId.slice(0, 8)}…`)
246
- }
247
- }
248
-
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
- /** Minimal cross-chat snapshot for the `hi` peer-list section.
263
- * `startedAt` stays private so this is the documented read path. */
264
- peerSnapshot(): { name: string; status: Status; uptimeMs?: number } {
265
- return {
266
- name: this.sessionName,
267
- status: this.status,
268
- uptimeMs: this.startedAt ? (Date.now() - this.startedAt) : undefined,
269
- }
270
- }
271
-
272
- get workDir(): string { return join(feishu.PROJECTS_ROOT, this.sessionName) }
273
- isRunning(): boolean { return !!this.proc && this.proc.isAlive() }
274
-
275
- // ── Lifecycle ──────────────────────────────────────────────────────
276
- async start(): Promise<boolean> {
277
- if (this.isRunning()) return true
278
- if (!feishu.isAnthropicAuthenticated()) {
279
- await feishu.sendText(this.chatId, '❌ Claude 未登录 Anthropic 账号。\n请在服务器上运行 `claude auth login` 后再试。')
280
- return false
281
- }
282
- if (!existsSync(this.workDir)) {
283
- await feishu.sendText(this.chatId, `🆕 目录 ~/${this.sessionName} 不存在,正在创建…`)
284
- try { feishu.provisionProject(this.workDir) }
285
- catch (e) {
286
- await feishu.sendText(this.chatId, `❌ 创建项目失败: ${e}`)
287
- return false
288
- }
289
- }
290
-
291
- this.status = 'starting'
292
- this.proc = new ClaudeProcess({
293
- workDir: this.workDir,
294
- effort: 'max',
295
- permissionMode: this.opts.permissionMode ?? 'bypassPermissions',
296
- appendSystemPrompt: CHANNEL_INSTRUCTIONS,
297
- })
298
- this.wireProc(this.proc)
299
- this.proc.sendInitialize({})
300
-
301
- await feishu.sendText(this.chatId, `✅ Lodestar session "${this.sessionName}" 已就绪,发消息开始对话。`)
302
- this.status = 'idle'
303
- this.startedAt = Date.now()
304
- return true
305
- }
306
-
307
- /** Drop every ⏳ OneSecond reaction this session is currently holding
308
- * on user chat messages, then empty the two tracking maps. Used by
309
- * every tear-down path (proc exit, kill, restart) so reactions don't
310
- * outlive the conversation that placed them — without this, a Claude
311
- * crash / daemon SIGTERM leaves orphan ⏳ stuck on user messages until
312
- * Feishu's UI eventually GCs them (which it doesn't, in practice).
313
- * closeTurnCard has its own release pass (with the slightly-early
314
- * merged-batch trade-off documented there); this is the catastrophic-
315
- * exit pass. Direct `deleteReaction` calls are fire-and-forget and
316
- * swallow their own failures (see feishu.deleteReaction). */
317
- private releaseAllReactions(): void {
318
- for (const [msgId, rid] of [
319
- ...this.pendingReactionIds.entries(),
320
- ...this.currentBatchReactionIds.entries(),
321
- ]) {
322
- if (rid) void feishu.deleteReaction(msgId, rid)
323
- }
324
- this.pendingReactionIds = new Map()
325
- this.currentBatchReactionIds = new Map()
326
- }
327
-
328
- async stop(reason = '已终止'): Promise<void> {
329
- if (!this.proc) {
330
- this.status = 'stopped'
331
- await feishu.sendText(this.chatId, `⚪ session "${this.sessionName}" 当前未运行`)
332
- return
333
- }
334
- // Flip lifecycle state SYNCHRONOUSLY before awaiting kill — daemon's
335
- // SIGTERM cleanup snapshots `isRunning()` and if we're still mid-
336
- // `proc.kill()` await it'll see proc!=null and write us into the
337
- // alive marker, which makes the next boot auto-revive a session
338
- // the user explicitly killed. Reordering the null-out fixes that
339
- // race (bug observed 2026-05-15: `kill` immediately followed by
340
- // `systemctl restart` revived the killed session on boot).
341
- log(`session "${this.sessionName}": stop (${reason})`)
342
- const proc = this.proc
343
- this.lastSessionId = proc.sessionId ?? this.lastSessionId
344
- this.proc = null
345
- this.currentTurn = null
346
- this.pendingUserMessageCount = 0
347
- this.pendingMidTurnMsgs = []
348
- this.lastUserOpenId = ''
349
- this.releaseAllReactions()
350
- this.initCount = 0
351
- this.openingTurn = false
352
- this.pendingPermissions.clear()
353
- this.status = 'stopped'
354
- await proc.kill()
355
- await feishu.sendText(this.chatId, `🔴 ${reason} (session: ${this.sessionName})`)
356
- }
357
-
358
- async restart(resume = false): Promise<void> {
359
- const prevSessionId = this.proc?.sessionId ?? this.lastSessionId
360
- if (this.proc) {
361
- this.lastSessionId = this.proc.sessionId ?? this.lastSessionId
362
- await this.proc.kill()
363
- this.proc = null
364
- }
365
- this.currentTurn = null
366
- this.pendingUserMessageCount = 0
367
- this.pendingMidTurnMsgs = []
368
- this.lastUserOpenId = ''
369
- this.releaseAllReactions()
370
- this.initCount = 0
371
- this.openingTurn = false
372
- this.pendingPermissions.clear()
373
- if (resume && prevSessionId) {
374
- this.proc = new ClaudeProcess({
375
- workDir: this.workDir,
376
- effort: 'max',
377
- permissionMode: this.opts.permissionMode ?? 'bypassPermissions',
378
- resumeSessionId: prevSessionId,
379
- appendSystemPrompt: CHANNEL_INSTRUCTIONS,
380
- })
381
- this.wireProc(this.proc)
382
- this.proc.sendInitialize({})
383
- this.status = 'idle'
384
- this.startedAt = Date.now()
385
- await feishu.sendText(this.chatId, `🔁 已重启并恢复 session=${prevSessionId.slice(0, 8)}…`)
386
- } else {
387
- // Resume requested but no prior session_id on file — surface it
388
- // explicitly rather than silently fresh-starting (the old behavior
389
- // hid the daemon-restart sessionId-loss bug for months).
390
- if (resume) {
391
- await feishu.sendText(this.chatId, '⚠️ 没有可恢复的上一会话,将以新会话启动')
392
- }
393
- // Fresh conversation — drop cumulative stats so the next `hi` shows
394
- // zeroed counters instead of bleeding numbers from the prior chat.
395
- this.cumStats = { tokens: 0, costUsd: 0, turns: 0 }
396
- this.lastTurnDelta = null
397
- await this.start()
398
- }
399
- }
400
-
401
- /** Run a bare-text control command (`hi`, `stop`, `kill`, `restart`, `clear`).
402
- * Returns true if the command was consumed (don't forward to Claude).
403
- * Exact match, case-insensitive, ignores trailing whitespace.
404
- *
405
- * Trade-off (user-confirmed 2026-05-15): these words are reserved
406
- * globally — typing "hi" as a literal greeting will show the console
407
- * card instead of reaching Claude. The ergonomic win (no slash, no
408
- * shift key, one-handed phone use) outweighs the collision in this
409
- * product's private-bot use case. `stop` was added 2026-05-15 once
410
- * auto-interrupt on mid-turn user messages was removed (matching
411
- * claude-code's native type-ahead behavior) — explicit barge-out
412
- * needed a knob and `kill` (full subprocess teardown) is too heavy. */
413
- async runCommand(raw: string): Promise<boolean> {
414
- switch (raw.trim().toLowerCase()) {
415
- case 'hi':
416
- if (!this.isRunning()) {
417
- const ok = await this.start()
418
- if (!ok) return true
419
- }
420
- await this.showConsole()
421
- return true
422
- case 'stop':
423
- // Soft barge-out: interrupt the current turn (if any) AND drop
424
- // the pending-message count so a stack of type-ahead doesn't
425
- // refire after the interrupt. Subprocess stays alive. Note: the
426
- // SDK keeps its OWN internal queue of the user-text frames we
427
- // already sendText'd — interrupt should also flush that side,
428
- // but the daemon can't reach into it directly; in practice the
429
- // sendInterrupt() control_request causes the SDK to discard
430
- // queued input alongside the in-flight call.
431
- if (!this.currentTurn && this.pendingUserMessageCount === 0 && this.pendingMidTurnMsgs.length === 0) {
432
- await feishu.sendText(this.chatId, '⚪ 当前没有正在执行的 turn')
433
- return true
434
- }
435
- log(`session "${this.sessionName}": stop command — interrupt + drop count=${this.pendingUserMessageCount} midBuffer=${this.pendingMidTurnMsgs.length}`)
436
- // Cancelled queued msgs: remove the OneSecond (no longer waiting)
437
- // and stamp a CrossMark (explicit cancelled state, distinct from
438
- // a natural release where reactions just disappear). Cancelled
439
- // mid-batch msgs get the same treatment.
440
- for (const [msgId, rid] of [
441
- ...this.pendingReactionIds.entries(),
442
- ...this.currentBatchReactionIds.entries(),
443
- ]) {
444
- if (rid) void feishu.deleteReaction(msgId, rid)
445
- void feishu.addReaction(msgId, 'CrossMark')
446
- }
447
- // Mid-turn buffer never reached SDK — cancel those too.
448
- for (const msg of this.pendingMidTurnMsgs) {
449
- if (msg.msgId) void feishu.addReaction(msg.msgId, 'CrossMark')
450
- }
451
- this.pendingUserMessageCount = 0
452
- this.pendingMidTurnMsgs = []
453
- this.lastUserOpenId = ''
454
- this.pendingReactionIds = new Map()
455
- this.currentBatchReactionIds = new Map()
456
- this.interrupt()
457
- // SDK 收到 interrupt 后不发 `result`,没人会触发 closeTurnCard。
458
- // 这里主动封口,把 footer 改成 🛑 打断、折叠 thinking、把
459
- // streaming_mode 翻回 false,否则卡片会僵在 `⏳ working…`。
460
- await this.closeTurnCard('🛑 打断')
461
- return true
462
- case 'kill':
463
- await this.stop()
464
- return true
465
- case 'restart':
466
- // resume the prior conversation — kills the current proc (if
467
- // any) and spawns a new one with `--resume <lastSessionId>`.
468
- // If no process is running, this is how the user gets back the
469
- // previous conversation after a `kill` or a daemon crash.
470
- await this.restart(true)
471
- return true
472
- case 'clear':
473
- // "throw away current conversation, start a new one". By design
474
- // this only makes sense when there IS a current conversation:
475
- // calling clear from stopped state is a no-op (user-confirmed
476
- // 2026-05-16) — we don't want a stray `clear` to silently spawn
477
- // a fresh session the user didn't ask for. To start from cold,
478
- // use `hi`.
479
- if (!this.isRunning()) {
480
- await feishu.sendText(this.chatId, `⚪ session "${this.sessionName}" 当前未运行,clear 无效;用 \`hi\` 启动或 \`restart\` 恢复上一会话`)
481
- return true
482
- }
483
- await this.restart(false)
484
- return true
485
- }
486
- return false
487
- }
488
-
489
- async showConsole(): Promise<void> {
490
- const uptimeMs = this.startedAt ? (Date.now() - this.startedAt) : undefined
491
- // Strip the `claude-` prefix so the panel stays compact: `opus-4-7`
492
- // reads better than `claude-opus-4-7` in the small status header.
493
- const rawModel = this.proc?.lastModel ?? null
494
- const model = rawModel ? rawModel.replace(/^claude-/, '') : undefined
495
- const card = cards.consoleCard({
496
- sessionName: this.sessionName,
497
- status: this.status,
498
- model,
499
- effort: 'max',
500
- uptimeMs,
501
- peers: [...Session.all]
502
- .filter(s => s.isRunning())
503
- .map(s => ({ ...s.peerSnapshot(), isCurrent: s === this })),
504
- // Initial paint without usage → cards.ts renders the
505
- // `_加载中…_` placeholder in the consoleUsage element. We patch
506
- // it in below once readUsage() resolves (ccusage cold-call is
507
- // ~5s; not worth blocking the panel on it).
508
- usage: undefined,
509
- contextTokens: this.currentContextTokens(),
510
- contextLimit: this.contextWindowMax(),
511
- cumStats: this.cumStats,
512
- lastTurn: this.lastTurnDelta
513
- ? {
514
- tokens: this.lastTurnDelta.tokens,
515
- costUsd: this.lastTurnDelta.costUsd,
516
- durationMs: this.lastTurnDelta.durationMs,
517
- }
518
- : undefined,
519
- sessionId: this.proc?.sessionId ?? this.lastSessionId,
520
- })
521
- const messageId = await feishu.sendCard(this.chatId, card)
522
- if (!messageId) return
523
- // Patch the usage element asynchronously so the rest of the panel
524
- // stays responsive. We don't await; failures are logged and the
525
- // placeholder stays visible (no fallback fabrication).
526
- void (async () => {
527
- try {
528
- const cardId = await cardkit.convertMessageToCard(messageId)
529
- const usage = await readUsage()
530
- await cardkit.replaceElement(cardId, cards.ELEMENTS.consoleUsage, {
531
- tag: 'markdown',
532
- element_id: cards.ELEMENTS.consoleUsage,
533
- content: cards.consoleUsageContent(usage),
534
- })
535
- } catch (e) { log(`session "${this.sessionName}": consoleUsage patch failed: ${e}`) }
536
- })()
537
- }
538
-
539
- interrupt(): void {
540
- if (!this.proc) return
541
- log(`session "${this.sessionName}": interrupt`)
542
- this.proc.sendInterrupt()
543
- }
544
-
545
- // ── Inbound from Feishu ────────────────────────────────────────────
546
- /** Inbound user message. Always writes to Claude's stdin immediately —
547
- * the SDK queues internally if a turn is in flight (FIFO, exactly the
548
- * type-ahead semantics of the native claude-code REPL). Card opening:
549
- * - First msg of session OR no turn in flight → open card eagerly here
550
- * - Mid-flight msg → defer; the `init`
551
- * handler opens its card when the SDK actually starts the turn
552
- * This is what lets a single subprocess host both user-typed turns and
553
- * cron-fired wakeups without the daemon ever calling `sendInterrupt` —
554
- * `kill`/`stop` are the only paths that interrupt now. */
555
- async onUserMessage(text: string, files: string[] = [], userOpenId = '', msgId = ''): Promise<void> {
556
- if (!this.isRunning()) {
557
- const ok = await this.start()
558
- if (!ok) return
559
- }
560
- // Garbage-collect leftover state from a batch the SDK abandoned —
561
- // most commonly an AskUserQuestion mid-turn, which makes the SDK
562
- // emit `QUEUE remove × N` and drop every msg we'd already
563
- // sendText'd into its queue. The daemon doesn't see those remove
564
- // events, so `pendingUserMessageCount` and `pendingReactionIds`
565
- // stay stuck. If the SDK is idle right now (no turn, no eager-
566
- // open in flight) AND init has already fired at least once
567
- // (otherwise we'd be in the bootstrap race window where
568
- // leftover count IS valid — see wasBusy comment below), the
569
- // leftover count is stale and must be cleared BEFORE the
570
- // wasBusy computation — otherwise this fresh solo message gets
571
- // falsely wrapped `<u>…</u>` and its card closes with
572
- // `📨 转交新卡` instead of `✅`.
573
- if (this.initCount >= 1 && !this.currentTurn && !this.openingTurn && this.pendingUserMessageCount > 0) {
574
- this.pendingUserMessageCount = 0
575
- // Release stale ⏳ reactions left on the abandoned batch's
576
- // chat messages. addReaction callbacks still in flight will
577
- // fall through to the orphan path in the wasBusy branch
578
- // below (which deletes whatever rid lands after both maps
579
- // are empty).
580
- for (const [m, rid] of this.pendingReactionIds) {
581
- if (rid) void feishu.deleteReaction(m, rid)
582
- }
583
- this.pendingReactionIds = new Map()
584
- }
585
- // Capture busy-state SYNC, before any state mutation — this decides
586
- // whether the message will visibly queue (gets the OneSecond → later
587
- // CheckMark lifecycle reactions on its Feishu chat message) or
588
- // eager-open its own card (no reaction needed; the card itself is
589
- // the acknowledgement).
590
- //
591
- // `pendingUserMessageCount > 0` catches the bootstrap race: daemon
592
- // just spawned, `initCount` is still 0 so no card is open yet, but
593
- // we've already sendText'd a previous user message into the SDK.
594
- // The next message lands in the SAME merged-batch SDK queue, so
595
- // it IS mid-flight from the SDK's perspective — without this
596
- // check, the daemon would mark it as solo (no `<u>` wrap, no ⏳
597
- // reaction) and the model would see e.g. "123" + "321" + "1"
598
- // glued into a single string "1233211" (2026-05-16 accumulator
599
- // bug).
600
- const wasBusy = this.currentTurn !== null || this.openingTurn
601
- || this.pendingUserMessageCount > 0 || this.pendingMidTurnMsgs.length > 0
602
- this.lastUserOpenId = userOpenId
603
- // When the SDK will merge this msg with siblings into a multi-
604
- // content user turn, wrap it in `<u>...</u>` so the model sees a
605
- // structural boundary it actually attends to. Tried U+001E
606
- // (ASCII Record Separator) first — invisible and theoretically
607
- // perfect, but Anthropic's tokenizer effectively drops control
608
- // chars and `<u>1</u><u>45</u>` became "145" to the model
609
- // (2026-05-16 accumulator test). HTML-tag wrap is visible but
610
- // models parse `<tag>` boundaries very reliably from training.
611
- // Only the very first solo message of a fresh SDK turn slot
612
- // skips the wrap — no sibling, no merge, no need. Contract
613
- // declared in CHANNEL_INSTRUCTIONS.
614
- const wireText = wasBusy ? `<u>${text}</u>` : text
615
-
616
- // Reaction helper: track the OneSecond reaction so deleteReaction can
617
- // clear it later. Use empty-string sentinel until addReaction returns.
618
- const trackReaction = (id: string) => {
619
- this.pendingReactionIds.set(id, '')
620
- void (async () => {
621
- const rid = await feishu.addReaction(id, 'OneSecond')
622
- if (!rid) return
623
- if (this.pendingReactionIds.has(id)) {
624
- this.pendingReactionIds.set(id, rid)
625
- } else if (this.currentBatchReactionIds.has(id)) {
626
- this.currentBatchReactionIds.set(id, rid)
627
- } else {
628
- // Orphan: both maps cleared before our add returned. Delete
629
- // directly so the user doesn't see a stale ⏳ forever.
630
- void feishu.deleteReaction(id, rid)
631
- }
632
- })()
633
- }
634
-
635
- if (this.currentTurn !== null) {
636
- // Mid-turn — BUFFER instead of immediate sendUserText. The SDK polling
637
- // loop will not auto-dequeue queued type-ahead msgs after `result`
638
- // (only `priority="now"` writes wake it — claude-code issue #39632),
639
- // so writing here would leave the msg stuck until the next user msg
640
- // arrives. Drain happens in the `result` handler, which both wakes
641
- // the SDK and opens a fresh card for the new batch turn.
642
- this.pendingMidTurnMsgs.push({ wireText, files, userOpenId, msgId })
643
- if (msgId) trackReaction(msgId)
644
- return
645
- }
646
-
647
- // No in-flight turn: send straight to SDK. This path handles
648
- // - first message after spawn (init not yet fired)
649
- // - bootstrap race (sibling msgs landing before init#1)
650
- // - solo message after a prior turn has fully closed
651
- // Eager-open path: open the card BEFORE feeding SDK, so a card-open
652
- // failure doesn't strand the daemon with SDK processing a turn we
653
- // have nowhere to render. `!openingTurn` means no sibling is mid-
654
- // open; `initCount >= 1` means SDK boot init has fired (otherwise
655
- // the init handler owns turn opening and we just feed the queue
656
- // below). On failure openTurnCard surfaces a red banner via
657
- // sendTextRaw; SDK was idle so no interrupt needed.
658
- if (!this.openingTurn && this.initCount >= 1) {
659
- this.openingTurn = true
660
- try {
661
- await this.openTurnCard(userOpenId, 'user_message')
662
- if (!this.currentTurn) return
663
- this.proc!.sendUserText(wireText, files)
664
- this.pendingUserMessageCount++
665
- this.status = 'working'
666
- } finally {
667
- this.openingTurn = false
668
- }
669
- return
670
- }
671
-
672
- // Non-eager path: either init hasn't fired yet (cold start) or a
673
- // sibling onUserMessage is already opening. Feed SDK directly; the
674
- // init handler / sibling card-opener will batch this message in.
675
- this.proc!.sendUserText(wireText, files)
676
- this.pendingUserMessageCount++
677
- if (wasBusy && msgId) {
678
- // Bootstrap race / sibling-opening race: until a card is open,
679
- // the OneSecond ⏳ is the only ack the user gets. The init handler
680
- // inherits these via currentBatchReactionIds when it opens.
681
- trackReaction(msgId)
682
- }
683
- }
684
-
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)
693
-
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
- hasPendingAsk(): boolean {
728
- return this.pendingAsks.size > 0
729
- }
730
-
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
738
- }
739
-
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 })
773
- }
774
-
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 })
787
- }
788
-
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
- }
870
- }
871
-
872
- // ── Wiring Claude → Feishu ─────────────────────────────────────────
873
- private wireProc(p: ClaudeProcess): void {
874
- p.on('init', () => {
875
- // Persist the freshly assigned session_id so a later daemon
876
- // restart can resume this conversation. Skip if unchanged to
877
- // avoid hammering the file on every init for resumed sessions.
878
- if (p.sessionId && p.sessionId !== this.lastSessionId) {
879
- this.lastSessionId = p.sessionId
880
- feishu.bindSessionResume(this.sessionName, p.sessionId)
881
- }
882
- this.initCount++
883
- log(`session "${this.sessionName}": SDK init#${this.initCount} pendingCount=${this.pendingUserMessageCount} midBuffer=${this.pendingMidTurnMsgs.length} currentTurn=${this.currentTurn ? 'yes' : 'no'} openingTurn=${this.openingTurn}`)
884
-
885
- // Boot init (initCount === 1) is claimed by `onUserMessage`'s
886
- // eager-open path — if a user message landed before the init
887
- // arrived, it sits in `pendingUserMessageCount` and we drain it
888
- // below; otherwise the init opens nothing. Subsequent inits
889
- // (initCount >= 2) mark the start of an SDK-initiated turn:
890
- // either the SDK is draining the type-ahead queue we fed it via
891
- // `sendUserText` (isUserBatch), or it's a CronCreate /
892
- // ScheduleWakeup fire from idle (isScheduledFire).
893
- //
894
- // SDK-driven rotation puts the boundary HERE: the previous
895
- // turn's `result` already closed the in-flight card with
896
- // `📨 转交新卡` (because pendingUserMessageCount > 0). Now we
897
- // open a fresh card whose top panel shows the queued messages.
898
- // currentTurn should be null at this point (result null'd it);
899
- // the openingTurn guard catches the eager-open vs init race.
900
- if (this.currentTurn || this.openingTurn) return
901
- const isUserBatch = this.pendingUserMessageCount > 0
902
- const isScheduledFire = !isUserBatch && this.initCount > 1
903
- if (!isUserBatch && !isScheduledFire) return
904
- const userOpenId = isUserBatch ? this.lastUserOpenId : ''
905
- if (isUserBatch) {
906
- this.pendingUserMessageCount = 0
907
- // Inherit the queued reaction_ids — this turn is collectively
908
- // responsible for releasing their OneSecond reactions when it
909
- // closes (via deleteReaction in closeTurnCard).
910
- this.currentBatchReactionIds = this.pendingReactionIds
911
- this.pendingReactionIds = new Map()
912
- }
913
- this.openingTurn = true
914
- void (async () => {
915
- try {
916
- await this.openTurnCard(userOpenId, isUserBatch ? 'user_message' : 'scheduled')
917
- if (!this.currentTurn) {
918
- // SDK already started this turn (its `init` is what got us
919
- // here) but we have no card to render into. Interrupt so
920
- // assistant/tool events aren't silently dropped while the
921
- // model burns tokens. Release the reactions this batch
922
- // inherited (init handler moved them above) — otherwise
923
- // they stay ⏳ forever on the user's chat messages.
924
- log(`session "${this.sessionName}": init-path openTurnCard failed — sendInterrupt + release reactions`)
925
- this.proc?.sendInterrupt()
926
- this.releaseAllReactions()
927
- } else {
928
- this.status = 'working'
929
- }
930
- } finally {
931
- this.openingTurn = false
932
- }
933
- })()
934
- })
935
- p.on('assistant_text', ({ text }: { text: string }) => {
936
- this.appendAssistant(text)
937
- })
938
- p.on('thinking', ({ text }: { text: string }) => {
939
- this.appendThinking(text)
940
- })
941
- p.on('tool_use', ({ id, name, input }: { id: string; name: string; input: any }) => {
942
- this.addTool(id, name, input)
943
- })
944
- p.on('tool_result', ({ tool_use_id, content, is_error }: any) => {
945
- this.completeTool(tool_use_id, content, is_error)
946
- })
947
- p.on('can_use_tool', (req: CanUseToolRequest) => {
948
- this.renderPermission(req)
949
- })
950
- p.on('hook_callback', (req: HookCallbackRequest) => {
951
- // No hooks registered → fail-safe ack.
952
- this.proc?.sendHookResponse(req.request_id, {})
953
- })
954
- p.on('result', () => {
955
- 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.
965
- 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)
969
- this.status = 'idle'
970
- if (hasMidTurn) void this.drainMidTurnAndOpen()
971
- })
972
- p.on('exit', ({ code, signal, expected }: any) => {
973
- log(`session "${this.sessionName}": claude exited code=${code} signal=${signal} expected=${expected}`)
974
- this.proc = null
975
- this.currentTurn = null
976
- this.pendingUserMessageCount = 0
977
- this.pendingMidTurnMsgs = []
978
- this.lastUserOpenId = ''
979
- this.releaseAllReactions()
980
- this.initCount = 0
981
- this.openingTurn = false
982
- this.status = 'stopped'
983
- if (!expected && code !== 0 && signal !== 'SIGTERM') {
984
- void feishu.sendText(this.chatId, `⚠️ Claude 异常退出 (code=${code}, signal=${signal})。回复任意消息将重新启动。`)
985
- }
986
- })
987
- }
988
-
989
- /** Pull per-turn numbers off `proc.lastResult` (set by ClaudeProcess when
990
- * the `result` message landed) and roll them into cumStats + the
991
- * "上一轮" delta. Called exactly once per result event, right before
992
- * closeTurnCard. */
993
- private accumulateResultStats(): void {
994
- const r = this.proc?.lastResult
995
- if (!r) return
996
- const u = r.usage ?? {}
997
- const inputTokens = (u.input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0) + (u.cache_read_input_tokens ?? 0)
998
- const outputTokens = u.output_tokens ?? 0
999
- const tokens = inputTokens + outputTokens
1000
- const costUsd = r.cost_usd ?? 0
1001
- const durationMs = r.duration_ms ?? 0
1002
- this.cumStats.tokens += tokens
1003
- this.cumStats.costUsd += costUsd
1004
- this.cumStats.turns += r.num_turns ?? 1
1005
- this.lastTurnDelta = { tokens, costUsd, durationMs, inputTokens }
1006
- }
1007
-
1008
- /** Current context-window occupancy estimate — uses the most recent
1009
- * assistant `usage` (input + caches), since each assistant reply replays
1010
- * the full conversation. Returns 0 when no per-call usage is available
1011
- * (process dead, or fresh spawn before first assistant message);
1012
- * `lastTurnDelta.inputTokens` is the CUMULATIVE turn input across all
1013
- * API calls in the turn (sum of cache_read across N steps) — using it
1014
- * here would inflate the percentage by Nx after a heavy multi-step
1015
- * turn (observed bug 2026-05-16: 417% in the `hi` panel after killing
1016
- * the proc with a long turn's delta still on file). */
1017
- private currentContextTokens(): number {
1018
- const u = this.proc?.lastUsage as ClaudeUsage | null | undefined
1019
- if (!u) return 0
1020
- return (u.input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0) + (u.cache_read_input_tokens ?? 0)
1021
- }
1022
-
1023
- /** Context-window capacity for the model the subprocess is currently
1024
- * running — sourced authoritatively from `result.modelUsage[model]
1025
- * .contextWindow` captured by ClaudeProcess on each turn close, so
1026
- * the daemon doesn't have to enumerate model ids itself (was the
1027
- * 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
1031
- }
1032
-
1033
- /** Drain `pendingMidTurnMsgs` to the SDK and open a fresh card for the
1034
- * resulting batch turn. Called from the `result` handler when buffered
1035
- * mid-turn messages need to start their own turn. The `sendUserText`
1036
- * calls wake the SDK polling loop (priority="now" semantics) and
1037
- * comprise the input for the new turn. Opens the card here rather
1038
- * than deferring to init because the init for this batch will arrive
1039
- * with `currentTurn` already set and bail.
1040
- *
1041
- * Each sendUserText also bumps `pendingUserMessageCount`. The SDK
1042
- * USUALLY collapses our N writes into one merged turn, but **not
1043
- * always** — empirically observed 2026-05-17, test1 accumulator
1044
- * session: when the first write lands in an idle SDK (turn just
1045
- * ended), the SDK eagerly starts a turn for that msg alone, then
1046
- * merges the rest into a second turn. Without the bump here, that
1047
- * second turn fires an `init` with `pendingUserMessageCount === 0`
1048
- * and the init handler misclassifies it as a scheduled wakeup,
1049
- * painting the `⏰ 触发` banner on what is really a user batch. */
1050
- private async drainMidTurnAndOpen(): Promise<void> {
1051
- if (this.pendingMidTurnMsgs.length === 0) return
1052
- const batch = this.pendingMidTurnMsgs
1053
- this.pendingMidTurnMsgs = []
1054
- this.openingTurn = true
1055
- try {
1056
- for (const msg of batch) {
1057
- this.proc!.sendUserText(msg.wireText, msg.files)
1058
- this.pendingUserMessageCount++
1059
- if (msg.msgId) {
1060
- const rid = this.pendingReactionIds.get(msg.msgId) ?? ''
1061
- this.currentBatchReactionIds.set(msg.msgId, rid)
1062
- this.pendingReactionIds.delete(msg.msgId)
1063
- }
1064
- }
1065
- const last = batch[batch.length - 1]
1066
- const userOpenId = last?.userOpenId ?? this.lastUserOpenId
1067
- await this.openTurnCard(userOpenId, 'user_message')
1068
- this.status = 'working'
1069
- } finally {
1070
- this.openingTurn = false
1071
- }
1072
- }
1073
-
1074
- private async openTurnCard(userOpenId: string, trigger: 'user_message' | 'scheduled'): Promise<void> {
1075
- const turn = ++this.turnCounter
1076
- log(`session "${this.sessionName}": openTurnCard turn=${turn} trigger=${trigger}`)
1077
- const card = cards.mainConversationCard({
1078
- sessionName: this.sessionName,
1079
- turn,
1080
- effort: 'max',
1081
- kind: trigger,
1082
- })
1083
- const messageId = await feishu.sendCard(this.chatId, card)
1084
- if (!messageId) {
1085
- log(`session "${this.sessionName}": openTurnCard sendCard EXHAUSTED retries — surfacing via raw text`)
1086
- // sendCard already retried 3× through the SDK. If it still came back
1087
- // null we're either on a sustained SDK-axios outage or a Feishu
1088
- // business reject. Either way the user just sent us a message and
1089
- // it's gone into a black hole — surface that explicitly so they
1090
- // know to resend instead of waiting for a reply that won't come.
1091
- // Use raw fetch (not sendText) because if the SDK is the broken
1092
- // thing we'd be doomed to silence otherwise.
1093
- await feishu.sendTextRaw(
1094
- this.chatId,
1095
- '❌ 创建对话卡片失败 (Feishu SDK 重试 3 次后仍连不上)。你这条消息没能送到 Claude,请稍后重发。',
1096
- )
1097
- // currentTurn left null as the failure signal. Caller decides
1098
- // whether to sendInterrupt: onUserMessage's eager-open path
1099
- // hasn't fed SDK yet so doesn't need to; the init handler has
1100
- // (SDK started the turn itself) and must.
1101
- return
1102
- }
1103
- let cardId: string
1104
- try { cardId = await cardkit.convertMessageToCard(messageId) }
1105
- catch (e) { log(`session "${this.sessionName}": id_convert failed: ${e}`); return }
1106
- this.currentTurn = {
1107
- cardId,
1108
- messageId,
1109
- userOpenId,
1110
- trigger,
1111
- thinkingText: '',
1112
- toolCount: 0,
1113
- toolByUseId: new Map(),
1114
- readBatches: new Map(),
1115
- openReadBatchI: null,
1116
- assistantSegmentCount: 0,
1117
- currentAssistantSegmentId: null,
1118
- currentAssistantText: '',
1119
- segmentTexts: new Map(),
1120
- startedAt: Date.now(),
1121
- }
1122
- }
1123
-
1124
- // Stream-event handlers are intentionally SYNCHRONOUS. Every cardkit op
1125
- // is queued (per-card Promise chain in cardkit.ts), so we fire-and-
1126
- // forget here and rely on enqueue source order — that way no `await`
1127
- // can yield mid-handler and let `closeTurnCard` (or another event) race
1128
- // and mutate `this.currentTurn` underfoot.
1129
- private appendAssistant(delta: string): void {
1130
- if (!this.currentTurn) return
1131
- const turn = this.currentTurn
1132
- if (!turn.currentAssistantSegmentId) {
1133
- // New assistant segment opens a visual break — any prior Read run
1134
- // is now visually separated from future Reads, so close the batch
1135
- // window. Future Reads will start a fresh batch at a new i.
1136
- turn.openReadBatchI = null
1137
- const i = turn.assistantSegmentCount++
1138
- const segId = cards.ELEMENTS.assistant(i)
1139
- turn.currentAssistantSegmentId = segId
1140
- turn.currentAssistantText = ''
1141
- void cardkit.addElement(turn.cardId, cards.assistantSegmentElement(i), {
1142
- type: 'insert_before', targetElementId: cards.ELEMENTS.footer,
1143
- }, () => {
1144
- // addElement永久失败:reset segmentId 让下次 delta 重新创建
1145
- // segment,否则后续 streamText 全都 PUT 到不存在的 element,
1146
- // 整段 assistant text 在用户那看不到。守 segId 不变以防 turn
1147
- // rotation 或 addTool 已经清掉了它(每次 addElement 闭包带的
1148
- // 是自己创建那次的 segId,只清自己的)。
1149
- if (turn.currentAssistantSegmentId === segId) {
1150
- log(`session "${this.sessionName}": assistant segment ${segId} addElement failed — will retry on next delta`)
1151
- turn.currentAssistantSegmentId = null
1152
- turn.currentAssistantText = ''
1153
- turn.segmentTexts.delete(segId)
1154
- }
1155
- })
1156
- }
1157
- turn.currentAssistantText += delta
1158
- const segId = turn.currentAssistantSegmentId
1159
- if (!segId) return // addElement 已失败 reset,等下一次 delta 重建
1160
- turn.segmentTexts.set(segId, turn.currentAssistantText)
1161
- cardkit.streamTextThrottled(turn.cardId, segId, turn.currentAssistantText)
1162
- // Chat-list preview: tail of the latest assistant text. Feishu
1163
- // truncates anyway; ~60 chars is what shows on a typical phone
1164
- // preview line. patchSummaryThrottled is rate-limited on its own.
1165
- const tail = turn.currentAssistantText.slice(-60)
1166
- cardkit.patchSummaryThrottled(turn.cardId, tail)
1167
- }
1168
-
1169
- private appendThinking(delta: string): void {
1170
- if (!this.currentTurn) return
1171
- this.currentTurn.thinkingText += delta
1172
- cardkit.streamTextThrottled(
1173
- this.currentTurn.cardId,
1174
- cards.ELEMENTS.thinking,
1175
- this.currentTurn.thinkingText,
1176
- )
1177
- }
1178
-
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> {
1481
- // CRITICAL: capture-and-null in a single synchronous block at entry
1482
- // so a parallel `closeTurnCard` (e.g. result event firing while
1483
- // onUserMessage is awaiting an interrupt) can't double-process the
1484
- // same turn — second caller observes null and bails. The promised
1485
- // sync-handler invariant only protects callers that take the turn
1486
- // off the table BEFORE their first await.
1487
- const turn = this.currentTurn
1488
- if (!turn) return
1489
- this.currentTurn = null
1490
- const elapsed = ((Date.now() - turn.startedAt) / 1000).toFixed(1)
1491
- const cardId = turn.cardId
1492
- const thinkingText = turn.thinkingText
1493
- const segmentTexts = turn.segmentTexts
1494
- await cardkit.flush(cardId)
1495
-
1496
- // [[send: /abs/path]] markers — strip them from each assistant
1497
- // segment and collect paths to upload after the card finalizes.
1498
- const sendPaths: string[] = []
1499
- for (const [segId, fullText] of segmentTexts) {
1500
- let changed = false
1501
- const cleaned = fullText.replace(SEND_MARKER_RE, (_m, p1) => {
1502
- changed = true
1503
- const p = String(p1).trim()
1504
- if (p.startsWith('/')) sendPaths.push(p)
1505
- else log(`session "${this.sessionName}": ignore non-absolute send path: ${p}`)
1506
- return ''
1507
- })
1508
- if (changed) {
1509
- const finalText = cleaned.trim() || ' '
1510
- await cardkit.replaceElement(cardId, segId, {
1511
- tag: 'markdown', element_id: segId, content: finalText,
1512
- })
1513
- }
1514
- }
1515
-
1516
- if (thinkingText.trim()) {
1517
- await cardkit.replaceElement(cardId, cards.ELEMENTS.thinking, cards.thinkingCollapsedPanel(thinkingText))
1518
- }
1519
- const sendNote = sendPaths.length ? ` · 📎 ${sendPaths.length}` : ''
1520
- // State marker leads the footer (✅ for natural completion, or the
1521
- // suffix verbatim for non-natural states like `🛑 打断`). The
1522
- // trailing "done" word is gone — the ✅ already carries that
1523
- // meaning. User-confirmed footer order 2026-05-16.
1524
- const stateMark = suffix ? suffix : '✅'
1525
- // Per-turn metrics: context-window occupancy (as a real percentage,
1526
- // not a token count) and dollar cost. Only meaningful on a clean
1527
- // close — suffix-tagged turns (interrupt) didn't fire the `result`
1528
- // event that populates `lastTurnDelta`, so these numbers would be
1529
- // stale and misleading.
1530
- let metrics = ''
1531
- if (!suffix) {
1532
- const ctxTokens = this.currentContextTokens()
1533
- const ctxMax = this.contextWindowMax()
1534
- if (ctxTokens > 0 && ctxMax > 0) {
1535
- const pct = Math.round((ctxTokens / ctxMax) * 100)
1536
- metrics += ` · 📊 ${pct}%`
1537
- }
1538
- const cost = this.lastTurnDelta?.costUsd ?? 0
1539
- if (cost > 0) metrics += ` · 💰 $${cost.toFixed(3)}`
1540
- }
1541
- const footer = `${stateMark} ⏱ ${elapsed}s${metrics}${sendNote}`
1542
- await cardkit.streamText(cardId, cards.ELEMENTS.footer, footer)
1543
- // Final chat-list preview: clean finish shows "⏱ Xs · NK tokens";
1544
- // interrupted shows the suffix instead (no usage event landed).
1545
- // cancelSummary kills any in-flight throttled write so a stale
1546
- // mid-stream tail can't clobber this terminal summary.
1547
- cardkit.cancelSummary(cardId)
1548
- await cardkit.patchSettings(cardId, cards.streamingOffSettings({
1549
- durationSec: elapsed,
1550
- tokens: suffix ? undefined : this.lastTurnDelta?.tokens,
1551
- suffix,
1552
- }))
1553
- await cardkit.dispose(cardId)
1554
-
1555
- // Phone push on clean turn close so the user knows Claude is done
1556
- // even with the chat backgrounded. Skip on interrupts (no real
1557
- // completion), when we don't know who to ping, and when the turn
1558
- // wasn't kicked off by the user typing a message — scheduled /
1559
- // 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) {
1563
- void feishu.urgentApp(turn.messageId, [turn.userOpenId])
1564
- }
1565
-
1566
- // Release the OneSecond reactions on every queued Feishu message
1567
- // this turn was responsible for. Two buckets:
1568
- // 1. `currentBatchReactionIds` — msgs the init handler explicitly
1569
- // claimed (SDK dequeued them as a merged next-turn batch).
1570
- // 2. `pendingReactionIds` — msgs whose fate is invisible to the
1571
- // daemon: the SDK either dequeued them as part of the
1572
- // JUST-CLOSED turn OR injected them mid-turn as
1573
- // `<system-reminder>` and silently removed them from the
1574
- // queue (common when the current turn had tool calls).
1575
- // Without visibility into queue-operation events the daemon
1576
- // can't tell which; the safe default is "the prior turn just
1577
- // ended, so the msg is at least *acknowledged* now —
1578
- // release the OneSecond and let it stop saying 'queued',
1579
- // instead of leaving it stuck permanently."
1580
- // For merged-batch follow-ups, this releases slightly early
1581
- // (before the merged turn actually runs), which is an
1582
- // acceptable trade vs. msgs stuck under OneSecond forever.
1583
- const releaseEntries = [
1584
- ...this.currentBatchReactionIds.entries(),
1585
- ...this.pendingReactionIds.entries(),
1586
- ]
1587
- if (releaseEntries.length > 0) {
1588
- for (const [msgId, rid] of releaseEntries) {
1589
- if (rid) void feishu.deleteReaction(msgId, rid)
1590
- }
1591
- this.currentBatchReactionIds = new Map()
1592
- this.pendingReactionIds = new Map()
1593
- }
1594
-
1595
- // Fire uploads sequentially AFTER the card is sealed so each file
1596
- // 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
- for (const p of sendPaths) {
1603
- await feishu.uploadAndSend(this.chatId, p, allowedRoots)
1604
- }
1605
- }
1606
- }