@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/README.md +74 -41
- package/dist/lodestar-setup.js +12 -0
- package/dist/lodestar.js +147 -0
- package/package.json +15 -6
- package/scripts/postinstall.cjs +97 -0
- package/daemon.ts +0 -355
- package/src/cardkit.ts +0 -349
- package/src/cards/console.ts +0 -352
- package/src/cards/elements.ts +0 -22
- package/src/cards/turn.ts +0 -530
- package/src/cards.ts +0 -32
- package/src/claude-process.ts +0 -417
- package/src/config.ts +0 -98
- package/src/feishu.ts +0 -498
- package/src/instructions.ts +0 -13
- package/src/log.ts +0 -11
- package/src/notify.ts +0 -132
- package/src/paths.ts +0 -57
- package/src/session-ask.ts +0 -165
- package/src/session-permission.ts +0 -136
- package/src/session-tools.ts +0 -233
- package/src/session-types.ts +0 -91
- package/src/session.ts +0 -1137
- package/src/sysinfo.ts +0 -273
- package/src/usage.ts +0 -327
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
|
-
}
|