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