@leviyuan/lodestar 0.2.8 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +121 -40
- 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 -353
- package/src/cardkit.ts +0 -349
- package/src/cards.ts +0 -798
- package/src/claude-process.ts +0 -395
- package/src/config.ts +0 -83
- package/src/feishu.ts +0 -531
- package/src/instructions.ts +0 -13
- package/src/log.ts +0 -11
- package/src/paths.ts +0 -57
- package/src/session.ts +0 -1606
- package/src/usage.ts +0 -327
package/src/session.ts
DELETED
|
@@ -1,1606 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Session — 1 Feishu chat ↔ 1 Claude headless process ↔ 1 streaming card.
|
|
3
|
-
*
|
|
4
|
-
* Owns the ClaudeProcess lifecycle, the per-turn card state machine, and
|
|
5
|
-
* the in-flight permission map. Wires Claude's stdout events into Card
|
|
6
|
-
* Kit ops, and wires Feishu inbound (text + card-action callbacks) into
|
|
7
|
-
* Claude's stdin.
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
import { existsSync } from 'node:fs'
|
|
11
|
-
import { join } from 'node:path'
|
|
12
|
-
import { ClaudeProcess, type CanUseToolRequest, type HookCallbackRequest, type ClaudeUsage } from './claude-process'
|
|
13
|
-
import { CHANNEL_INSTRUCTIONS } from './instructions'
|
|
14
|
-
import * as cardkit from './cardkit'
|
|
15
|
-
import * as cards from './cards'
|
|
16
|
-
import * as feishu from './feishu'
|
|
17
|
-
import { log } from './log'
|
|
18
|
-
import { INBOX_DIR } from './paths'
|
|
19
|
-
import { readUsage } from './usage'
|
|
20
|
-
|
|
21
|
-
interface TurnState {
|
|
22
|
-
cardId: string
|
|
23
|
-
/** Feishu message_id of the card — needed for urgent_app push on clean
|
|
24
|
-
* turn close. Kept separate from cardId because cardkit's stream APIs
|
|
25
|
-
* operate on card_id but the urgent_app endpoint takes message_id. */
|
|
26
|
-
messageId: string
|
|
27
|
-
/** open_id of the user who started this turn. Used to scope the
|
|
28
|
-
* urgent_app push so only the initiator gets pinged (in case there
|
|
29
|
-
* are other members in the group). Empty string → skip the ping. */
|
|
30
|
-
userOpenId: string
|
|
31
|
-
/** What kicked off this turn. Only `'user_message'` turns fire the
|
|
32
|
-
* end-of-turn urgent_app push — scheduled / cron / loop wakeups
|
|
33
|
-
* finish on their own time and pinging the user would be noise,
|
|
34
|
-
* not signal. Ask / permission urgents inside the turn still fire
|
|
35
|
-
* regardless (those genuinely need attention even mid-schedule). */
|
|
36
|
-
trigger: 'user_message' | 'scheduled'
|
|
37
|
-
thinkingText: string
|
|
38
|
-
toolCount: number
|
|
39
|
-
/** `output` / `isError` are filled in by completeTool — kept on the
|
|
40
|
-
* meta (instead of being thrown away after the first render) so a
|
|
41
|
-
* later Task* op can re-render every prior Task* panel with the
|
|
42
|
-
* latest todo mirror appended. */
|
|
43
|
-
toolByUseId: Map<string, {
|
|
44
|
-
i: number
|
|
45
|
-
name: string
|
|
46
|
-
input: any
|
|
47
|
-
resolvedNote?: string
|
|
48
|
-
output?: string
|
|
49
|
-
isError?: boolean
|
|
50
|
-
/** Set when this tool is part of a merged Read batch — points to the
|
|
51
|
-
* batch's slot in `readBatches[i].items`. completeTool uses it to
|
|
52
|
-
* update the right row instead of rendering a standalone panel. */
|
|
53
|
-
readBatchSlot?: number
|
|
54
|
-
}>
|
|
55
|
-
/** Consecutive `Read` calls collapse into a single panel rendered by
|
|
56
|
-
* `cards.readBatchElement`. Keyed by element index `i` so completeTool
|
|
57
|
-
* can find the batch after its open-window closed (a non-Read tool or
|
|
58
|
-
* new assistant segment has since arrived).
|
|
59
|
-
*
|
|
60
|
-
* `openReadBatchI` is the i of the batch currently accepting new Reads;
|
|
61
|
-
* null once the run ends. Subsequent Read calls open a fresh batch at a
|
|
62
|
-
* new i. */
|
|
63
|
-
readBatches: Map<number, {
|
|
64
|
-
items: Array<{ toolUseId: string; input: any; output: string | null; isError: boolean }>
|
|
65
|
-
}>
|
|
66
|
-
openReadBatchI: number | null
|
|
67
|
-
assistantSegmentCount: number
|
|
68
|
-
currentAssistantSegmentId: string | null
|
|
69
|
-
currentAssistantText: string
|
|
70
|
-
// Per-assistant-segment cumulative text — used at turn close to strip
|
|
71
|
-
// [[send: /path]] markers and replace each segment with a cleaned
|
|
72
|
-
// version, then post the files as separate Feishu messages.
|
|
73
|
-
segmentTexts: Map<string, string>
|
|
74
|
-
startedAt: number
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
const SEND_MARKER_RE = /\[\[send:\s*([^\]\n]+?)\s*\]\]/g
|
|
78
|
-
|
|
79
|
-
type Status = 'idle' | 'working' | 'awaiting_permission' | 'starting' | 'stopped'
|
|
80
|
-
|
|
81
|
-
export interface SessionOpts {
|
|
82
|
-
permissionMode?: 'default' | 'acceptEdits' | 'plan' | 'bypassPermissions'
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
/** Per-turn delta extracted from the SDK `result` message — feeds the
|
|
86
|
-
* "上一轮" line in the console panel. */
|
|
87
|
-
interface LastTurnDelta {
|
|
88
|
-
tokens: number // input + cache_* + output for that turn
|
|
89
|
-
costUsd: number
|
|
90
|
-
durationMs: number
|
|
91
|
-
inputTokens: number // input + cache_* (excludes output) — context-window estimate
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
/** Cumulative session counters. Reset on full restart (`clear`),
|
|
95
|
-
* preserved across `restart`/resume and daemon-restart so the `hi`
|
|
96
|
-
* panel reflects the user's total spend in this conversation
|
|
97
|
-
* regardless of how many times the underlying ClaudeProcess has been
|
|
98
|
-
* respawned. Resumed conversations start counting from the resume
|
|
99
|
-
* point onward — the SDK doesn't replay historical usage on resume,
|
|
100
|
-
* so a long pre-resume conversation shows up as zero here until the
|
|
101
|
-
* first new turn lands. */
|
|
102
|
-
interface CumStats {
|
|
103
|
-
tokens: number
|
|
104
|
-
costUsd: number
|
|
105
|
-
turns: number
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
export class Session {
|
|
109
|
-
/** Process-wide registry of every Session ever constructed in this daemon.
|
|
110
|
-
* Used by the `hi` console panel to enumerate sibling sessions across
|
|
111
|
-
* Feishu groups. Sessions are never removed (matches the daemon's
|
|
112
|
-
* `sessions` map lifecycle — one Session per chat for the daemon's
|
|
113
|
-
* lifetime). Callers should filter on `isRunning()` when they only
|
|
114
|
-
* want currently-alive Claude processes. */
|
|
115
|
-
static readonly all: Set<Session> = new Set()
|
|
116
|
-
|
|
117
|
-
private proc: ClaudeProcess | null = null
|
|
118
|
-
private currentTurn: TurnState | null = null
|
|
119
|
-
/** Count of user messages we've written to Claude's stdin since the last
|
|
120
|
-
* turn opened on our side. NOT a FIFO of individual messages — the SDK
|
|
121
|
-
* USUALLY batch-merges every mid-turn user message into a single
|
|
122
|
-
* combined turn once the in-flight turn finishes, so most of the time
|
|
123
|
-
* the daemon observes **one** init event per batch. Tracking a count +
|
|
124
|
-
* last-sender (rather than an Array<msg>) keeps the daemon's view
|
|
125
|
-
* loosely in sync with the SDK's dequeue semantics. Caveat verified
|
|
126
|
-
* 2026-05-17 (test1 accumulator, 8-message rapid-fire): when the first
|
|
127
|
-
* write lands in an idle SDK, that single msg gets its own turn and
|
|
128
|
-
* the rest merge into a second turn — i.e. 1+(N-1) split, not always
|
|
129
|
-
* one merge. To stay coherent with this, `drainMidTurnAndOpen` bumps
|
|
130
|
-
* the count by `batch.length` up front (covering both the first solo
|
|
131
|
-
* turn and the eventual merged tail), and the init handler resets to
|
|
132
|
-
* 0 on the first claim. If the SDK takes the merge path, the bail at
|
|
133
|
-
* `currentTurn=yes` in the init handler leaves pendingCount stale (>0)
|
|
134
|
-
* until the GC at the next `onUserMessage`; if it takes the split
|
|
135
|
-
* path, the second init sees pendingCount>0 and correctly classifies
|
|
136
|
-
* the trailing batch as user-batch (not a scheduled wakeup).
|
|
137
|
-
* Distinguishes user-msg turns from cron-fired scheduled
|
|
138
|
-
* wakeups: count > 0 ⇒ user; count === 0 ⇒ scheduled (and
|
|
139
|
-
* `initCount > 1`). */
|
|
140
|
-
private pendingUserMessageCount = 0
|
|
141
|
-
/** Mid-turn user messages buffered DAEMON-SIDE (not yet sendUserText'd
|
|
142
|
-
* to the SDK). Drained in the `result` handler by writing each to SDK
|
|
143
|
-
* stdin, which doubles as the `priority="now"` wake signal the SDK
|
|
144
|
-
* polling loop needs to start the next batch turn (the SDK won't
|
|
145
|
-
* auto-dequeue queued type-ahead msgs after `result` — confirmed via
|
|
146
|
-
* claude-code issue #39632). Buffering also keeps mid-turn msgs out
|
|
147
|
-
* of any AskUserQuestion `QUEUE remove` storm, since they were never
|
|
148
|
-
* in the SDK queue to begin with. */
|
|
149
|
-
private pendingMidTurnMsgs: Array<{ wireText: string; files: string[]; userOpenId: string; msgId: string }> = []
|
|
150
|
-
/** Most recent userOpenId seen via `onUserMessage`. Used only when a
|
|
151
|
-
* merged batch fires its init event and the daemon needs *some* open_id
|
|
152
|
-
* to scope the eventual `urgent_app` push — there's no obviously right
|
|
153
|
-
* answer when N messages from possibly different users collapse into
|
|
154
|
-
* one turn, and "the most recent sender" is a defensible default for
|
|
155
|
-
* the single-user private-bot scenario this product targets. */
|
|
156
|
-
private lastUserOpenId = ''
|
|
157
|
-
/** Feishu message_ids of user messages that arrived while the daemon
|
|
158
|
-
* was busy (turn in flight or mid-open), mapped to the `reaction_id`
|
|
159
|
-
* of the `OneSecond` reaction placed at arrival. The reaction_id is
|
|
160
|
-
* what `deleteReaction` needs to *remove* the OneSecond once the
|
|
161
|
-
* message has been absorbed by the SDK (either system-reminder
|
|
162
|
-
* injection mid-turn or a merged-batch dequeue on next turn).
|
|
163
|
-
* User feedback (2026-05-15): replacing OneSecond with a second
|
|
164
|
-
* CheckMark stacked two emojis on the same row; cleaner UX is
|
|
165
|
-
* "queued → released" via removal, not "queued → done" via
|
|
166
|
-
* stacking. */
|
|
167
|
-
private pendingReactionIds = new Map<string, string>()
|
|
168
|
-
/** Snapshot of `pendingReactionIds` taken when the init handler
|
|
169
|
-
* claims a merged batch — these are the Feishu messages whose
|
|
170
|
-
* OneSecond reactions are the currently-open turn's responsibility
|
|
171
|
-
* to clear (via deleteReaction). Empty for eager-opened solo turns
|
|
172
|
-
* and for scheduled wakeups (no user messages went into those). */
|
|
173
|
-
private currentBatchReactionIds = new Map<string, string>()
|
|
174
|
-
/** Count of `system/init` events seen this subprocess. The first one is
|
|
175
|
-
* the boot init (claimed by whichever user message lands first); all
|
|
176
|
-
* subsequent ones mark the start of an SDK-initiated turn (queued
|
|
177
|
-
* user message draining or a CronCreate fire). Reset on stop/restart/exit
|
|
178
|
-
* since `init` re-fires after every spawn. */
|
|
179
|
-
private initCount = 0
|
|
180
|
-
/** Sync guard set before any `await` in the eager-open path of
|
|
181
|
-
* `onUserMessage`, cleared after `currentTurn` is set. Closes the race
|
|
182
|
-
* where an SDK-emitted `init` event lands during the eager open's
|
|
183
|
-
* Feishu API await — without this, the init handler would observe
|
|
184
|
-
* `currentTurn === null && queue empty` (we've already shifted) and
|
|
185
|
-
* incorrectly open a *second* scheduled card for the same user
|
|
186
|
-
* message. The flag tells the init handler "an eager open is already
|
|
187
|
-
* claiming the slot, stand down". */
|
|
188
|
-
private openingTurn = false
|
|
189
|
-
private pendingPermissions = new Map<string, { toolUseId: string }>()
|
|
190
|
-
/** Open AskUserQuestion tool calls — keyed by tool_use_id. The SDK
|
|
191
|
-
* routes AskUserQuestion through the can_use_tool flow even under
|
|
192
|
-
* bypass; we have to thread the permission `requestId` through here
|
|
193
|
-
* so the answer (option click OR custom text submit) can resolve
|
|
194
|
-
* the permission with `updatedInput.answers` populated.
|
|
195
|
-
* `deferredAnswer` covers the race where the user clicks/submits
|
|
196
|
-
* BEFORE can_use_tool arrives (addTool fires on the assistant
|
|
197
|
-
* message; can_use_tool is a separate control_request that lands
|
|
198
|
-
* slightly later). */
|
|
199
|
-
private pendingAsks = new Map<string, {
|
|
200
|
-
questions: cards.AskQuestion[]
|
|
201
|
-
i: number
|
|
202
|
-
requestId?: string
|
|
203
|
-
/** 累计答案 — key 是 question 原文 (SDK 把这条 record 格式
|
|
204
|
-
* 化进 tool_result), value 是用户选的 option label 或自定
|
|
205
|
-
* 义文字。全部 question 都答完时一并塞进 updatedInput.answers
|
|
206
|
-
* 发回 SDK。 */
|
|
207
|
-
answers: Record<string, string>
|
|
208
|
-
/** 已答详情 (按 question idx 索引),用来给历史面板和 terminal
|
|
209
|
-
* 状态画选中态。answers 同步累计,但这里多保留 customText /
|
|
210
|
-
* optionIdx 字段以便 UI 区分两种回答路径。 */
|
|
211
|
-
answered: Map<number, cards.AskAnswered>
|
|
212
|
-
/** 当前展示的 question idx。undefined 表示全部答完 (terminal)
|
|
213
|
-
* —— 这时若 requestId 已就位则 finalize;否则等 renderPermission
|
|
214
|
-
* 一来立即 finalize。 */
|
|
215
|
-
currentIdx?: number
|
|
216
|
-
}>()
|
|
217
|
-
private turnCounter = 0
|
|
218
|
-
// Last seen sessionId — preserved across `kill`/`stop` so a later
|
|
219
|
-
// `restart` can resume the same Claude conversation even after the
|
|
220
|
-
// child process is gone.
|
|
221
|
-
private lastSessionId: string | null = null
|
|
222
|
-
private startedAt: number = 0
|
|
223
|
-
private cumStats: CumStats = { tokens: 0, costUsd: 0, turns: 0 }
|
|
224
|
-
private lastTurnDelta: LastTurnDelta | null = null
|
|
225
|
-
/** Local mirror of the SDK's task list — built incrementally from
|
|
226
|
-
* TaskCreate / TaskUpdate input+output pairs and rendered as a footer
|
|
227
|
-
* on every Task* panel. Lives for the lifetime of the Session
|
|
228
|
-
* instance; daemon restart wipes it (the SDK doesn't replay history).
|
|
229
|
-
* Not authoritative — Claude calling TaskList is still the source of
|
|
230
|
-
* truth; this mirror is purely for the panel readout. */
|
|
231
|
-
private currentTodos = new Map<number, cards.Todo>()
|
|
232
|
-
status: Status = 'stopped'
|
|
233
|
-
|
|
234
|
-
constructor(
|
|
235
|
-
public readonly sessionName: string,
|
|
236
|
-
public readonly chatId: string,
|
|
237
|
-
private opts: SessionOpts = {},
|
|
238
|
-
) {
|
|
239
|
-
Session.all.add(this)
|
|
240
|
-
// Restore last-known claude session_id from disk so a daemon restart
|
|
241
|
-
// (systemctl, crash, watchdog) doesn't strand the user with a fresh
|
|
242
|
-
// conversation when they next type `restart`.
|
|
243
|
-
this.lastSessionId = feishu.getSessionResume(sessionName)
|
|
244
|
-
if (this.lastSessionId) {
|
|
245
|
-
log(`session "${sessionName}": restored lastSessionId=${this.lastSessionId.slice(0, 8)}…`)
|
|
246
|
-
}
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
/** Patch the card-level summary (the text Feishu uses for chat-list
|
|
250
|
-
* preview AND lock-screen push), then return when the API call has
|
|
251
|
-
* landed. Used right before urgent_app so the push notification's
|
|
252
|
-
* derived preview describes the *action that needs attention* (an
|
|
253
|
-
* unanswered question, a pending permission ask) rather than the
|
|
254
|
-
* stale assistant-text tail that patchSummaryThrottled was streaming.
|
|
255
|
-
* cancelSummary kills any in-flight throttled write so our explicit
|
|
256
|
-
* patch isn't immediately clobbered. */
|
|
257
|
-
private async setUrgentSummary(cardId: string, content: string): Promise<void> {
|
|
258
|
-
cardkit.cancelSummary(cardId)
|
|
259
|
-
await cardkit.patchSettings(cardId, { config: { summary: { content } } })
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
/** Minimal cross-chat snapshot for the `hi` peer-list section.
|
|
263
|
-
* `startedAt` stays private so this is the documented read path. */
|
|
264
|
-
peerSnapshot(): { name: string; status: Status; uptimeMs?: number } {
|
|
265
|
-
return {
|
|
266
|
-
name: this.sessionName,
|
|
267
|
-
status: this.status,
|
|
268
|
-
uptimeMs: this.startedAt ? (Date.now() - this.startedAt) : undefined,
|
|
269
|
-
}
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
get workDir(): string { return join(feishu.PROJECTS_ROOT, this.sessionName) }
|
|
273
|
-
isRunning(): boolean { return !!this.proc && this.proc.isAlive() }
|
|
274
|
-
|
|
275
|
-
// ── Lifecycle ──────────────────────────────────────────────────────
|
|
276
|
-
async start(): Promise<boolean> {
|
|
277
|
-
if (this.isRunning()) return true
|
|
278
|
-
if (!feishu.isAnthropicAuthenticated()) {
|
|
279
|
-
await feishu.sendText(this.chatId, '❌ Claude 未登录 Anthropic 账号。\n请在服务器上运行 `claude auth login` 后再试。')
|
|
280
|
-
return false
|
|
281
|
-
}
|
|
282
|
-
if (!existsSync(this.workDir)) {
|
|
283
|
-
await feishu.sendText(this.chatId, `🆕 目录 ~/${this.sessionName} 不存在,正在创建…`)
|
|
284
|
-
try { feishu.provisionProject(this.workDir) }
|
|
285
|
-
catch (e) {
|
|
286
|
-
await feishu.sendText(this.chatId, `❌ 创建项目失败: ${e}`)
|
|
287
|
-
return false
|
|
288
|
-
}
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
this.status = 'starting'
|
|
292
|
-
this.proc = new ClaudeProcess({
|
|
293
|
-
workDir: this.workDir,
|
|
294
|
-
effort: 'max',
|
|
295
|
-
permissionMode: this.opts.permissionMode ?? 'bypassPermissions',
|
|
296
|
-
appendSystemPrompt: CHANNEL_INSTRUCTIONS,
|
|
297
|
-
})
|
|
298
|
-
this.wireProc(this.proc)
|
|
299
|
-
this.proc.sendInitialize({})
|
|
300
|
-
|
|
301
|
-
await feishu.sendText(this.chatId, `✅ Lodestar session "${this.sessionName}" 已就绪,发消息开始对话。`)
|
|
302
|
-
this.status = 'idle'
|
|
303
|
-
this.startedAt = Date.now()
|
|
304
|
-
return true
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
/** Drop every ⏳ OneSecond reaction this session is currently holding
|
|
308
|
-
* on user chat messages, then empty the two tracking maps. Used by
|
|
309
|
-
* every tear-down path (proc exit, kill, restart) so reactions don't
|
|
310
|
-
* outlive the conversation that placed them — without this, a Claude
|
|
311
|
-
* crash / daemon SIGTERM leaves orphan ⏳ stuck on user messages until
|
|
312
|
-
* Feishu's UI eventually GCs them (which it doesn't, in practice).
|
|
313
|
-
* closeTurnCard has its own release pass (with the slightly-early
|
|
314
|
-
* merged-batch trade-off documented there); this is the catastrophic-
|
|
315
|
-
* exit pass. Direct `deleteReaction` calls are fire-and-forget and
|
|
316
|
-
* swallow their own failures (see feishu.deleteReaction). */
|
|
317
|
-
private releaseAllReactions(): void {
|
|
318
|
-
for (const [msgId, rid] of [
|
|
319
|
-
...this.pendingReactionIds.entries(),
|
|
320
|
-
...this.currentBatchReactionIds.entries(),
|
|
321
|
-
]) {
|
|
322
|
-
if (rid) void feishu.deleteReaction(msgId, rid)
|
|
323
|
-
}
|
|
324
|
-
this.pendingReactionIds = new Map()
|
|
325
|
-
this.currentBatchReactionIds = new Map()
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
async stop(reason = '已终止'): Promise<void> {
|
|
329
|
-
if (!this.proc) {
|
|
330
|
-
this.status = 'stopped'
|
|
331
|
-
await feishu.sendText(this.chatId, `⚪ session "${this.sessionName}" 当前未运行`)
|
|
332
|
-
return
|
|
333
|
-
}
|
|
334
|
-
// Flip lifecycle state SYNCHRONOUSLY before awaiting kill — daemon's
|
|
335
|
-
// SIGTERM cleanup snapshots `isRunning()` and if we're still mid-
|
|
336
|
-
// `proc.kill()` await it'll see proc!=null and write us into the
|
|
337
|
-
// alive marker, which makes the next boot auto-revive a session
|
|
338
|
-
// the user explicitly killed. Reordering the null-out fixes that
|
|
339
|
-
// race (bug observed 2026-05-15: `kill` immediately followed by
|
|
340
|
-
// `systemctl restart` revived the killed session on boot).
|
|
341
|
-
log(`session "${this.sessionName}": stop (${reason})`)
|
|
342
|
-
const proc = this.proc
|
|
343
|
-
this.lastSessionId = proc.sessionId ?? this.lastSessionId
|
|
344
|
-
this.proc = null
|
|
345
|
-
this.currentTurn = null
|
|
346
|
-
this.pendingUserMessageCount = 0
|
|
347
|
-
this.pendingMidTurnMsgs = []
|
|
348
|
-
this.lastUserOpenId = ''
|
|
349
|
-
this.releaseAllReactions()
|
|
350
|
-
this.initCount = 0
|
|
351
|
-
this.openingTurn = false
|
|
352
|
-
this.pendingPermissions.clear()
|
|
353
|
-
this.status = 'stopped'
|
|
354
|
-
await proc.kill()
|
|
355
|
-
await feishu.sendText(this.chatId, `🔴 ${reason} (session: ${this.sessionName})`)
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
async restart(resume = false): Promise<void> {
|
|
359
|
-
const prevSessionId = this.proc?.sessionId ?? this.lastSessionId
|
|
360
|
-
if (this.proc) {
|
|
361
|
-
this.lastSessionId = this.proc.sessionId ?? this.lastSessionId
|
|
362
|
-
await this.proc.kill()
|
|
363
|
-
this.proc = null
|
|
364
|
-
}
|
|
365
|
-
this.currentTurn = null
|
|
366
|
-
this.pendingUserMessageCount = 0
|
|
367
|
-
this.pendingMidTurnMsgs = []
|
|
368
|
-
this.lastUserOpenId = ''
|
|
369
|
-
this.releaseAllReactions()
|
|
370
|
-
this.initCount = 0
|
|
371
|
-
this.openingTurn = false
|
|
372
|
-
this.pendingPermissions.clear()
|
|
373
|
-
if (resume && prevSessionId) {
|
|
374
|
-
this.proc = new ClaudeProcess({
|
|
375
|
-
workDir: this.workDir,
|
|
376
|
-
effort: 'max',
|
|
377
|
-
permissionMode: this.opts.permissionMode ?? 'bypassPermissions',
|
|
378
|
-
resumeSessionId: prevSessionId,
|
|
379
|
-
appendSystemPrompt: CHANNEL_INSTRUCTIONS,
|
|
380
|
-
})
|
|
381
|
-
this.wireProc(this.proc)
|
|
382
|
-
this.proc.sendInitialize({})
|
|
383
|
-
this.status = 'idle'
|
|
384
|
-
this.startedAt = Date.now()
|
|
385
|
-
await feishu.sendText(this.chatId, `🔁 已重启并恢复 session=${prevSessionId.slice(0, 8)}…`)
|
|
386
|
-
} else {
|
|
387
|
-
// Resume requested but no prior session_id on file — surface it
|
|
388
|
-
// explicitly rather than silently fresh-starting (the old behavior
|
|
389
|
-
// hid the daemon-restart sessionId-loss bug for months).
|
|
390
|
-
if (resume) {
|
|
391
|
-
await feishu.sendText(this.chatId, '⚠️ 没有可恢复的上一会话,将以新会话启动')
|
|
392
|
-
}
|
|
393
|
-
// Fresh conversation — drop cumulative stats so the next `hi` shows
|
|
394
|
-
// zeroed counters instead of bleeding numbers from the prior chat.
|
|
395
|
-
this.cumStats = { tokens: 0, costUsd: 0, turns: 0 }
|
|
396
|
-
this.lastTurnDelta = null
|
|
397
|
-
await this.start()
|
|
398
|
-
}
|
|
399
|
-
}
|
|
400
|
-
|
|
401
|
-
/** Run a bare-text control command (`hi`, `stop`, `kill`, `restart`, `clear`).
|
|
402
|
-
* Returns true if the command was consumed (don't forward to Claude).
|
|
403
|
-
* Exact match, case-insensitive, ignores trailing whitespace.
|
|
404
|
-
*
|
|
405
|
-
* Trade-off (user-confirmed 2026-05-15): these words are reserved
|
|
406
|
-
* globally — typing "hi" as a literal greeting will show the console
|
|
407
|
-
* card instead of reaching Claude. The ergonomic win (no slash, no
|
|
408
|
-
* shift key, one-handed phone use) outweighs the collision in this
|
|
409
|
-
* product's private-bot use case. `stop` was added 2026-05-15 once
|
|
410
|
-
* auto-interrupt on mid-turn user messages was removed (matching
|
|
411
|
-
* claude-code's native type-ahead behavior) — explicit barge-out
|
|
412
|
-
* needed a knob and `kill` (full subprocess teardown) is too heavy. */
|
|
413
|
-
async runCommand(raw: string): Promise<boolean> {
|
|
414
|
-
switch (raw.trim().toLowerCase()) {
|
|
415
|
-
case 'hi':
|
|
416
|
-
if (!this.isRunning()) {
|
|
417
|
-
const ok = await this.start()
|
|
418
|
-
if (!ok) return true
|
|
419
|
-
}
|
|
420
|
-
await this.showConsole()
|
|
421
|
-
return true
|
|
422
|
-
case 'stop':
|
|
423
|
-
// Soft barge-out: interrupt the current turn (if any) AND drop
|
|
424
|
-
// the pending-message count so a stack of type-ahead doesn't
|
|
425
|
-
// refire after the interrupt. Subprocess stays alive. Note: the
|
|
426
|
-
// SDK keeps its OWN internal queue of the user-text frames we
|
|
427
|
-
// already sendText'd — interrupt should also flush that side,
|
|
428
|
-
// but the daemon can't reach into it directly; in practice the
|
|
429
|
-
// sendInterrupt() control_request causes the SDK to discard
|
|
430
|
-
// queued input alongside the in-flight call.
|
|
431
|
-
if (!this.currentTurn && this.pendingUserMessageCount === 0 && this.pendingMidTurnMsgs.length === 0) {
|
|
432
|
-
await feishu.sendText(this.chatId, '⚪ 当前没有正在执行的 turn')
|
|
433
|
-
return true
|
|
434
|
-
}
|
|
435
|
-
log(`session "${this.sessionName}": stop command — interrupt + drop count=${this.pendingUserMessageCount} midBuffer=${this.pendingMidTurnMsgs.length}`)
|
|
436
|
-
// Cancelled queued msgs: remove the OneSecond (no longer waiting)
|
|
437
|
-
// and stamp a CrossMark (explicit cancelled state, distinct from
|
|
438
|
-
// a natural release where reactions just disappear). Cancelled
|
|
439
|
-
// mid-batch msgs get the same treatment.
|
|
440
|
-
for (const [msgId, rid] of [
|
|
441
|
-
...this.pendingReactionIds.entries(),
|
|
442
|
-
...this.currentBatchReactionIds.entries(),
|
|
443
|
-
]) {
|
|
444
|
-
if (rid) void feishu.deleteReaction(msgId, rid)
|
|
445
|
-
void feishu.addReaction(msgId, 'CrossMark')
|
|
446
|
-
}
|
|
447
|
-
// Mid-turn buffer never reached SDK — cancel those too.
|
|
448
|
-
for (const msg of this.pendingMidTurnMsgs) {
|
|
449
|
-
if (msg.msgId) void feishu.addReaction(msg.msgId, 'CrossMark')
|
|
450
|
-
}
|
|
451
|
-
this.pendingUserMessageCount = 0
|
|
452
|
-
this.pendingMidTurnMsgs = []
|
|
453
|
-
this.lastUserOpenId = ''
|
|
454
|
-
this.pendingReactionIds = new Map()
|
|
455
|
-
this.currentBatchReactionIds = new Map()
|
|
456
|
-
this.interrupt()
|
|
457
|
-
// SDK 收到 interrupt 后不发 `result`,没人会触发 closeTurnCard。
|
|
458
|
-
// 这里主动封口,把 footer 改成 🛑 打断、折叠 thinking、把
|
|
459
|
-
// streaming_mode 翻回 false,否则卡片会僵在 `⏳ working…`。
|
|
460
|
-
await this.closeTurnCard('🛑 打断')
|
|
461
|
-
return true
|
|
462
|
-
case 'kill':
|
|
463
|
-
await this.stop()
|
|
464
|
-
return true
|
|
465
|
-
case 'restart':
|
|
466
|
-
// resume the prior conversation — kills the current proc (if
|
|
467
|
-
// any) and spawns a new one with `--resume <lastSessionId>`.
|
|
468
|
-
// If no process is running, this is how the user gets back the
|
|
469
|
-
// previous conversation after a `kill` or a daemon crash.
|
|
470
|
-
await this.restart(true)
|
|
471
|
-
return true
|
|
472
|
-
case 'clear':
|
|
473
|
-
// "throw away current conversation, start a new one". By design
|
|
474
|
-
// this only makes sense when there IS a current conversation:
|
|
475
|
-
// calling clear from stopped state is a no-op (user-confirmed
|
|
476
|
-
// 2026-05-16) — we don't want a stray `clear` to silently spawn
|
|
477
|
-
// a fresh session the user didn't ask for. To start from cold,
|
|
478
|
-
// use `hi`.
|
|
479
|
-
if (!this.isRunning()) {
|
|
480
|
-
await feishu.sendText(this.chatId, `⚪ session "${this.sessionName}" 当前未运行,clear 无效;用 \`hi\` 启动或 \`restart\` 恢复上一会话`)
|
|
481
|
-
return true
|
|
482
|
-
}
|
|
483
|
-
await this.restart(false)
|
|
484
|
-
return true
|
|
485
|
-
}
|
|
486
|
-
return false
|
|
487
|
-
}
|
|
488
|
-
|
|
489
|
-
async showConsole(): Promise<void> {
|
|
490
|
-
const uptimeMs = this.startedAt ? (Date.now() - this.startedAt) : undefined
|
|
491
|
-
// Strip the `claude-` prefix so the panel stays compact: `opus-4-7`
|
|
492
|
-
// reads better than `claude-opus-4-7` in the small status header.
|
|
493
|
-
const rawModel = this.proc?.lastModel ?? null
|
|
494
|
-
const model = rawModel ? rawModel.replace(/^claude-/, '') : undefined
|
|
495
|
-
const card = cards.consoleCard({
|
|
496
|
-
sessionName: this.sessionName,
|
|
497
|
-
status: this.status,
|
|
498
|
-
model,
|
|
499
|
-
effort: 'max',
|
|
500
|
-
uptimeMs,
|
|
501
|
-
peers: [...Session.all]
|
|
502
|
-
.filter(s => s.isRunning())
|
|
503
|
-
.map(s => ({ ...s.peerSnapshot(), isCurrent: s === this })),
|
|
504
|
-
// Initial paint without usage → cards.ts renders the
|
|
505
|
-
// `_加载中…_` placeholder in the consoleUsage element. We patch
|
|
506
|
-
// it in below once readUsage() resolves (ccusage cold-call is
|
|
507
|
-
// ~5s; not worth blocking the panel on it).
|
|
508
|
-
usage: undefined,
|
|
509
|
-
contextTokens: this.currentContextTokens(),
|
|
510
|
-
contextLimit: this.contextWindowMax(),
|
|
511
|
-
cumStats: this.cumStats,
|
|
512
|
-
lastTurn: this.lastTurnDelta
|
|
513
|
-
? {
|
|
514
|
-
tokens: this.lastTurnDelta.tokens,
|
|
515
|
-
costUsd: this.lastTurnDelta.costUsd,
|
|
516
|
-
durationMs: this.lastTurnDelta.durationMs,
|
|
517
|
-
}
|
|
518
|
-
: undefined,
|
|
519
|
-
sessionId: this.proc?.sessionId ?? this.lastSessionId,
|
|
520
|
-
})
|
|
521
|
-
const messageId = await feishu.sendCard(this.chatId, card)
|
|
522
|
-
if (!messageId) return
|
|
523
|
-
// Patch the usage element asynchronously so the rest of the panel
|
|
524
|
-
// stays responsive. We don't await; failures are logged and the
|
|
525
|
-
// placeholder stays visible (no fallback fabrication).
|
|
526
|
-
void (async () => {
|
|
527
|
-
try {
|
|
528
|
-
const cardId = await cardkit.convertMessageToCard(messageId)
|
|
529
|
-
const usage = await readUsage()
|
|
530
|
-
await cardkit.replaceElement(cardId, cards.ELEMENTS.consoleUsage, {
|
|
531
|
-
tag: 'markdown',
|
|
532
|
-
element_id: cards.ELEMENTS.consoleUsage,
|
|
533
|
-
content: cards.consoleUsageContent(usage),
|
|
534
|
-
})
|
|
535
|
-
} catch (e) { log(`session "${this.sessionName}": consoleUsage patch failed: ${e}`) }
|
|
536
|
-
})()
|
|
537
|
-
}
|
|
538
|
-
|
|
539
|
-
interrupt(): void {
|
|
540
|
-
if (!this.proc) return
|
|
541
|
-
log(`session "${this.sessionName}": interrupt`)
|
|
542
|
-
this.proc.sendInterrupt()
|
|
543
|
-
}
|
|
544
|
-
|
|
545
|
-
// ── Inbound from Feishu ────────────────────────────────────────────
|
|
546
|
-
/** Inbound user message. Always writes to Claude's stdin immediately —
|
|
547
|
-
* the SDK queues internally if a turn is in flight (FIFO, exactly the
|
|
548
|
-
* type-ahead semantics of the native claude-code REPL). Card opening:
|
|
549
|
-
* - First msg of session OR no turn in flight → open card eagerly here
|
|
550
|
-
* - Mid-flight msg → defer; the `init`
|
|
551
|
-
* handler opens its card when the SDK actually starts the turn
|
|
552
|
-
* This is what lets a single subprocess host both user-typed turns and
|
|
553
|
-
* cron-fired wakeups without the daemon ever calling `sendInterrupt` —
|
|
554
|
-
* `kill`/`stop` are the only paths that interrupt now. */
|
|
555
|
-
async onUserMessage(text: string, files: string[] = [], userOpenId = '', msgId = ''): Promise<void> {
|
|
556
|
-
if (!this.isRunning()) {
|
|
557
|
-
const ok = await this.start()
|
|
558
|
-
if (!ok) return
|
|
559
|
-
}
|
|
560
|
-
// Garbage-collect leftover state from a batch the SDK abandoned —
|
|
561
|
-
// most commonly an AskUserQuestion mid-turn, which makes the SDK
|
|
562
|
-
// emit `QUEUE remove × N` and drop every msg we'd already
|
|
563
|
-
// sendText'd into its queue. The daemon doesn't see those remove
|
|
564
|
-
// events, so `pendingUserMessageCount` and `pendingReactionIds`
|
|
565
|
-
// stay stuck. If the SDK is idle right now (no turn, no eager-
|
|
566
|
-
// open in flight) AND init has already fired at least once
|
|
567
|
-
// (otherwise we'd be in the bootstrap race window where
|
|
568
|
-
// leftover count IS valid — see wasBusy comment below), the
|
|
569
|
-
// leftover count is stale and must be cleared BEFORE the
|
|
570
|
-
// wasBusy computation — otherwise this fresh solo message gets
|
|
571
|
-
// falsely wrapped `<u>…</u>` and its card closes with
|
|
572
|
-
// `📨 转交新卡` instead of `✅`.
|
|
573
|
-
if (this.initCount >= 1 && !this.currentTurn && !this.openingTurn && this.pendingUserMessageCount > 0) {
|
|
574
|
-
this.pendingUserMessageCount = 0
|
|
575
|
-
// Release stale ⏳ reactions left on the abandoned batch's
|
|
576
|
-
// chat messages. addReaction callbacks still in flight will
|
|
577
|
-
// fall through to the orphan path in the wasBusy branch
|
|
578
|
-
// below (which deletes whatever rid lands after both maps
|
|
579
|
-
// are empty).
|
|
580
|
-
for (const [m, rid] of this.pendingReactionIds) {
|
|
581
|
-
if (rid) void feishu.deleteReaction(m, rid)
|
|
582
|
-
}
|
|
583
|
-
this.pendingReactionIds = new Map()
|
|
584
|
-
}
|
|
585
|
-
// Capture busy-state SYNC, before any state mutation — this decides
|
|
586
|
-
// whether the message will visibly queue (gets the OneSecond → later
|
|
587
|
-
// CheckMark lifecycle reactions on its Feishu chat message) or
|
|
588
|
-
// eager-open its own card (no reaction needed; the card itself is
|
|
589
|
-
// the acknowledgement).
|
|
590
|
-
//
|
|
591
|
-
// `pendingUserMessageCount > 0` catches the bootstrap race: daemon
|
|
592
|
-
// just spawned, `initCount` is still 0 so no card is open yet, but
|
|
593
|
-
// we've already sendText'd a previous user message into the SDK.
|
|
594
|
-
// The next message lands in the SAME merged-batch SDK queue, so
|
|
595
|
-
// it IS mid-flight from the SDK's perspective — without this
|
|
596
|
-
// check, the daemon would mark it as solo (no `<u>` wrap, no ⏳
|
|
597
|
-
// reaction) and the model would see e.g. "123" + "321" + "1"
|
|
598
|
-
// glued into a single string "1233211" (2026-05-16 accumulator
|
|
599
|
-
// bug).
|
|
600
|
-
const wasBusy = this.currentTurn !== null || this.openingTurn
|
|
601
|
-
|| this.pendingUserMessageCount > 0 || this.pendingMidTurnMsgs.length > 0
|
|
602
|
-
this.lastUserOpenId = userOpenId
|
|
603
|
-
// When the SDK will merge this msg with siblings into a multi-
|
|
604
|
-
// content user turn, wrap it in `<u>...</u>` so the model sees a
|
|
605
|
-
// structural boundary it actually attends to. Tried U+001E
|
|
606
|
-
// (ASCII Record Separator) first — invisible and theoretically
|
|
607
|
-
// perfect, but Anthropic's tokenizer effectively drops control
|
|
608
|
-
// chars and `<u>1</u><u>45</u>` became "145" to the model
|
|
609
|
-
// (2026-05-16 accumulator test). HTML-tag wrap is visible but
|
|
610
|
-
// models parse `<tag>` boundaries very reliably from training.
|
|
611
|
-
// Only the very first solo message of a fresh SDK turn slot
|
|
612
|
-
// skips the wrap — no sibling, no merge, no need. Contract
|
|
613
|
-
// declared in CHANNEL_INSTRUCTIONS.
|
|
614
|
-
const wireText = wasBusy ? `<u>${text}</u>` : text
|
|
615
|
-
|
|
616
|
-
// Reaction helper: track the OneSecond reaction so deleteReaction can
|
|
617
|
-
// clear it later. Use empty-string sentinel until addReaction returns.
|
|
618
|
-
const trackReaction = (id: string) => {
|
|
619
|
-
this.pendingReactionIds.set(id, '')
|
|
620
|
-
void (async () => {
|
|
621
|
-
const rid = await feishu.addReaction(id, 'OneSecond')
|
|
622
|
-
if (!rid) return
|
|
623
|
-
if (this.pendingReactionIds.has(id)) {
|
|
624
|
-
this.pendingReactionIds.set(id, rid)
|
|
625
|
-
} else if (this.currentBatchReactionIds.has(id)) {
|
|
626
|
-
this.currentBatchReactionIds.set(id, rid)
|
|
627
|
-
} else {
|
|
628
|
-
// Orphan: both maps cleared before our add returned. Delete
|
|
629
|
-
// directly so the user doesn't see a stale ⏳ forever.
|
|
630
|
-
void feishu.deleteReaction(id, rid)
|
|
631
|
-
}
|
|
632
|
-
})()
|
|
633
|
-
}
|
|
634
|
-
|
|
635
|
-
if (this.currentTurn !== null) {
|
|
636
|
-
// Mid-turn — BUFFER instead of immediate sendUserText. The SDK polling
|
|
637
|
-
// loop will not auto-dequeue queued type-ahead msgs after `result`
|
|
638
|
-
// (only `priority="now"` writes wake it — claude-code issue #39632),
|
|
639
|
-
// so writing here would leave the msg stuck until the next user msg
|
|
640
|
-
// arrives. Drain happens in the `result` handler, which both wakes
|
|
641
|
-
// the SDK and opens a fresh card for the new batch turn.
|
|
642
|
-
this.pendingMidTurnMsgs.push({ wireText, files, userOpenId, msgId })
|
|
643
|
-
if (msgId) trackReaction(msgId)
|
|
644
|
-
return
|
|
645
|
-
}
|
|
646
|
-
|
|
647
|
-
// No in-flight turn: send straight to SDK. This path handles
|
|
648
|
-
// - first message after spawn (init not yet fired)
|
|
649
|
-
// - bootstrap race (sibling msgs landing before init#1)
|
|
650
|
-
// - solo message after a prior turn has fully closed
|
|
651
|
-
// Eager-open path: open the card BEFORE feeding SDK, so a card-open
|
|
652
|
-
// failure doesn't strand the daemon with SDK processing a turn we
|
|
653
|
-
// have nowhere to render. `!openingTurn` means no sibling is mid-
|
|
654
|
-
// open; `initCount >= 1` means SDK boot init has fired (otherwise
|
|
655
|
-
// the init handler owns turn opening and we just feed the queue
|
|
656
|
-
// below). On failure openTurnCard surfaces a red banner via
|
|
657
|
-
// sendTextRaw; SDK was idle so no interrupt needed.
|
|
658
|
-
if (!this.openingTurn && this.initCount >= 1) {
|
|
659
|
-
this.openingTurn = true
|
|
660
|
-
try {
|
|
661
|
-
await this.openTurnCard(userOpenId, 'user_message')
|
|
662
|
-
if (!this.currentTurn) return
|
|
663
|
-
this.proc!.sendUserText(wireText, files)
|
|
664
|
-
this.pendingUserMessageCount++
|
|
665
|
-
this.status = 'working'
|
|
666
|
-
} finally {
|
|
667
|
-
this.openingTurn = false
|
|
668
|
-
}
|
|
669
|
-
return
|
|
670
|
-
}
|
|
671
|
-
|
|
672
|
-
// Non-eager path: either init hasn't fired yet (cold start) or a
|
|
673
|
-
// sibling onUserMessage is already opening. Feed SDK directly; the
|
|
674
|
-
// init handler / sibling card-opener will batch this message in.
|
|
675
|
-
this.proc!.sendUserText(wireText, files)
|
|
676
|
-
this.pendingUserMessageCount++
|
|
677
|
-
if (wasBusy && msgId) {
|
|
678
|
-
// Bootstrap race / sibling-opening race: until a card is open,
|
|
679
|
-
// the OneSecond ⏳ is the only ack the user gets. The init handler
|
|
680
|
-
// inherits these via currentBatchReactionIds when it opens.
|
|
681
|
-
trackReaction(msgId)
|
|
682
|
-
}
|
|
683
|
-
}
|
|
684
|
-
|
|
685
|
-
async onPermissionDecision(
|
|
686
|
-
requestId: string,
|
|
687
|
-
decision: 'allow' | 'allow_always' | 'deny',
|
|
688
|
-
user: string,
|
|
689
|
-
): Promise<void> {
|
|
690
|
-
const pending = this.pendingPermissions.get(requestId)
|
|
691
|
-
if (!pending) { log(`session "${this.sessionName}": stray permission ${requestId}`); return }
|
|
692
|
-
this.pendingPermissions.delete(requestId)
|
|
693
|
-
|
|
694
|
-
// Update the tool element in the main turn card in place — the
|
|
695
|
-
// permission decision lives on the same row as the tool call.
|
|
696
|
-
const turn = this.currentTurn
|
|
697
|
-
const meta = turn?.toolByUseId.get(pending.toolUseId)
|
|
698
|
-
if (turn && meta) {
|
|
699
|
-
const todos = this.isTaskWorkflow(meta.name) ? this.todosArray() : undefined
|
|
700
|
-
if (decision === 'deny') {
|
|
701
|
-
const el = cards.toolCallElement(meta.i, meta.name, meta.input, `🚫 已拒绝 by ${user || '匿名'}`, '❌', undefined, todos)
|
|
702
|
-
void cardkit.replaceElement(turn.cardId, cards.ELEMENTS.tool(meta.i), el)
|
|
703
|
-
} else {
|
|
704
|
-
const label = decision === 'allow_always' ? '始终允许' : '已允许'
|
|
705
|
-
meta.resolvedNote = `✅ **${label}** by ${user || '匿名'}`
|
|
706
|
-
const el = cards.toolCallElement(meta.i, meta.name, meta.input, null, '⏳', meta.resolvedNote, todos)
|
|
707
|
-
void cardkit.replaceElement(turn.cardId, cards.ELEMENTS.tool(meta.i), el)
|
|
708
|
-
}
|
|
709
|
-
}
|
|
710
|
-
|
|
711
|
-
const claudeDecision = decision === 'deny' ? 'deny' : 'allow'
|
|
712
|
-
this.proc?.sendPermissionResponse(requestId, claudeDecision)
|
|
713
|
-
|
|
714
|
-
if (decision === 'allow_always') {
|
|
715
|
-
this.proc?.sendSetPermissionMode('acceptEdits')
|
|
716
|
-
}
|
|
717
|
-
|
|
718
|
-
if (this.pendingPermissions.size === 0 && this.status === 'awaiting_permission') {
|
|
719
|
-
this.status = 'working'
|
|
720
|
-
}
|
|
721
|
-
}
|
|
722
|
-
|
|
723
|
-
/** True iff there's at least one open AskUserQuestion awaiting an
|
|
724
|
-
* answer in this session. `daemon.handleMessage` uses this to
|
|
725
|
-
* decide whether an inbound chat message should be a custom answer
|
|
726
|
-
* (routed to onAskMessageAnswer) instead of opening a new turn. */
|
|
727
|
-
hasPendingAsk(): boolean {
|
|
728
|
-
return this.pendingAsks.size > 0
|
|
729
|
-
}
|
|
730
|
-
|
|
731
|
-
/** True iff a turn is currently running (or a queued user message is
|
|
732
|
-
* waiting for its turn to start). daemon uses this to drop a hourglass
|
|
733
|
-
* reaction on inbound messages — without it the user sees no visible
|
|
734
|
-
* acknowledgement that their type-ahead message landed (the card
|
|
735
|
-
* doesn't open until the current turn finishes). */
|
|
736
|
-
isBusy(): boolean {
|
|
737
|
-
return this.currentTurn !== null || this.pendingUserMessageCount > 0 || this.pendingMidTurnMsgs.length > 0
|
|
738
|
-
}
|
|
739
|
-
|
|
740
|
-
/** Funnel an arbitrary chat message into the *current* question
|
|
741
|
-
* of the oldest pending ask as a `customText` answer. Multi-
|
|
742
|
-
* question semantics: from the user's perspective, the chat
|
|
743
|
-
* input always answers whatever question is on screen right now
|
|
744
|
-
* (`pending.currentIdx`), and a new question slides in after. */
|
|
745
|
-
async onAskMessageAnswer(text: string, user: string): Promise<void> {
|
|
746
|
-
const firstEntry = this.pendingAsks.entries().next()
|
|
747
|
-
if (firstEntry.done) {
|
|
748
|
-
log(`session "${this.sessionName}": onAskMessageAnswer with no pending — falling back to onUserMessage`)
|
|
749
|
-
await this.onUserMessage(text)
|
|
750
|
-
return
|
|
751
|
-
}
|
|
752
|
-
const [toolUseId, pending] = firstEntry.value
|
|
753
|
-
if (pending.currentIdx === undefined) {
|
|
754
|
-
log(`session "${this.sessionName}": pending ask ${toolUseId} already terminal — ignoring message`)
|
|
755
|
-
return
|
|
756
|
-
}
|
|
757
|
-
await this.onAskCustomAnswer(toolUseId, pending.currentIdx, text, user)
|
|
758
|
-
}
|
|
759
|
-
|
|
760
|
-
/** Click handler for an option button. The click must target the
|
|
761
|
-
* question currently on screen (`pending.currentIdx`); a stale
|
|
762
|
-
* click (e.g. user clicked an older render before it swapped in
|
|
763
|
-
* the next question) is logged and dropped — better than double-
|
|
764
|
-
* answering. */
|
|
765
|
-
async onAskAnswer(toolUseId: string, questionIdx: number, optionIdx: number, user: string): Promise<void> {
|
|
766
|
-
const pending = this.pendingAsks.get(toolUseId)
|
|
767
|
-
if (!pending) { log(`session "${this.sessionName}": stray ask answer for ${toolUseId}`); return }
|
|
768
|
-
if (questionIdx !== pending.currentIdx) {
|
|
769
|
-
log(`session "${this.sessionName}": stale ask click q=${questionIdx} current=${pending.currentIdx}`)
|
|
770
|
-
return
|
|
771
|
-
}
|
|
772
|
-
this.advanceAsk(toolUseId, { optionIdx, user })
|
|
773
|
-
}
|
|
774
|
-
|
|
775
|
-
/** Custom-text branch. Same staleness rule as onAskAnswer; empty
|
|
776
|
-
* input is silently ignored (panel stays pending). */
|
|
777
|
-
async onAskCustomAnswer(toolUseId: string, questionIdx: number, customText: string, user: string): Promise<void> {
|
|
778
|
-
const pending = this.pendingAsks.get(toolUseId)
|
|
779
|
-
if (!pending) { log(`session "${this.sessionName}": stray ask custom for ${toolUseId}`); return }
|
|
780
|
-
const trimmed = (customText ?? '').trim()
|
|
781
|
-
if (!trimmed) { log(`session "${this.sessionName}": empty custom answer, ignoring`); return }
|
|
782
|
-
if (questionIdx !== pending.currentIdx) {
|
|
783
|
-
log(`session "${this.sessionName}": stale ask custom q=${questionIdx} current=${pending.currentIdx}`)
|
|
784
|
-
return
|
|
785
|
-
}
|
|
786
|
-
this.advanceAsk(toolUseId, { customText: trimmed, user })
|
|
787
|
-
}
|
|
788
|
-
|
|
789
|
-
/** Record an answer for the current question, advance the state
|
|
790
|
-
* machine, repaint. If every question is now answered, finalize
|
|
791
|
-
* (or defer the finalize until can_use_tool lands — the race is
|
|
792
|
-
* handled by renderPermission). */
|
|
793
|
-
private advanceAsk(
|
|
794
|
-
toolUseId: string,
|
|
795
|
-
answer: { optionIdx?: number; customText?: string; user: string },
|
|
796
|
-
): void {
|
|
797
|
-
const pending = this.pendingAsks.get(toolUseId)
|
|
798
|
-
if (!pending || pending.currentIdx === undefined) return
|
|
799
|
-
const cur = pending.currentIdx
|
|
800
|
-
const q = pending.questions[cur]
|
|
801
|
-
if (!q) { log(`session "${this.sessionName}": advanceAsk currentIdx=${cur} out of range`); return }
|
|
802
|
-
// Resolve the literal answer value — custom text wins if both set.
|
|
803
|
-
let value: string
|
|
804
|
-
if (answer.customText !== undefined) {
|
|
805
|
-
value = answer.customText
|
|
806
|
-
} else if (answer.optionIdx !== undefined) {
|
|
807
|
-
const opt = q.options?.[answer.optionIdx]
|
|
808
|
-
if (!opt) { log(`session "${this.sessionName}": advanceAsk option ${answer.optionIdx} out of range`); return }
|
|
809
|
-
value = opt.label
|
|
810
|
-
} else {
|
|
811
|
-
log(`session "${this.sessionName}": advanceAsk with neither customText nor optionIdx`)
|
|
812
|
-
return
|
|
813
|
-
}
|
|
814
|
-
pending.answers[q.question] = value
|
|
815
|
-
pending.answered.set(cur, {
|
|
816
|
-
optionIdx: answer.optionIdx,
|
|
817
|
-
customText: answer.customText,
|
|
818
|
-
user: answer.user,
|
|
819
|
-
})
|
|
820
|
-
// Next unanswered idx — linear from cur+1. Implementation
|
|
821
|
-
// always moves forward; we don't currently let users revisit a
|
|
822
|
-
// previous question (would need richer UI affordance for that).
|
|
823
|
-
const total = pending.questions.length
|
|
824
|
-
let nextIdx: number | undefined = undefined
|
|
825
|
-
for (let i = cur + 1; i < total; i++) {
|
|
826
|
-
if (!pending.answered.has(i)) { nextIdx = i; break }
|
|
827
|
-
}
|
|
828
|
-
pending.currentIdx = nextIdx
|
|
829
|
-
|
|
830
|
-
const turn = this.currentTurn
|
|
831
|
-
const meta = turn?.toolByUseId.get(toolUseId)
|
|
832
|
-
if (turn && meta) {
|
|
833
|
-
const el = cards.askUserQuestionElement(
|
|
834
|
-
meta.i, toolUseId, pending.questions,
|
|
835
|
-
nextIdx === undefined ? '✅' : '🤔',
|
|
836
|
-
{ currentIdx: nextIdx, answered: pending.answered },
|
|
837
|
-
)
|
|
838
|
-
void cardkit.replaceElement(turn.cardId, cards.ELEMENTS.tool(meta.i), el)
|
|
839
|
-
}
|
|
840
|
-
|
|
841
|
-
if (nextIdx === undefined) {
|
|
842
|
-
// All done. Finalize iff we have the permission request id;
|
|
843
|
-
// otherwise renderPermission will pick it up when it arrives.
|
|
844
|
-
if (pending.requestId) this.finalizeAsk(toolUseId)
|
|
845
|
-
else log(`session "${this.sessionName}": ask ${toolUseId} all answered, waiting for can_use_tool`)
|
|
846
|
-
}
|
|
847
|
-
}
|
|
848
|
-
|
|
849
|
-
/** Settle a fully-answered AskUserQuestion: emit the SDK allow
|
|
850
|
-
* with the full `answers` record folded into `updatedInput`,
|
|
851
|
-
* drop bookkeeping, restore status. The terminal panel paint was
|
|
852
|
-
* already done by the final advanceAsk; this is just protocol. */
|
|
853
|
-
private finalizeAsk(toolUseId: string): void {
|
|
854
|
-
const pending = this.pendingAsks.get(toolUseId)
|
|
855
|
-
if (!pending || !pending.requestId) return
|
|
856
|
-
const meta = this.currentTurn?.toolByUseId.get(toolUseId)
|
|
857
|
-
const originalInput = meta?.input ?? {}
|
|
858
|
-
this.proc?.sendPermissionResponse(pending.requestId, 'allow', {
|
|
859
|
-
updatedInput: { ...originalInput, answers: pending.answers },
|
|
860
|
-
})
|
|
861
|
-
this.pendingPermissions.delete(pending.requestId)
|
|
862
|
-
if (meta) {
|
|
863
|
-
meta.output = JSON.stringify({ answers: pending.answers })
|
|
864
|
-
meta.isError = false
|
|
865
|
-
}
|
|
866
|
-
this.pendingAsks.delete(toolUseId)
|
|
867
|
-
if (this.pendingPermissions.size === 0 && this.status === 'awaiting_permission') {
|
|
868
|
-
this.status = 'working'
|
|
869
|
-
}
|
|
870
|
-
}
|
|
871
|
-
|
|
872
|
-
// ── Wiring Claude → Feishu ─────────────────────────────────────────
|
|
873
|
-
private wireProc(p: ClaudeProcess): void {
|
|
874
|
-
p.on('init', () => {
|
|
875
|
-
// Persist the freshly assigned session_id so a later daemon
|
|
876
|
-
// restart can resume this conversation. Skip if unchanged to
|
|
877
|
-
// avoid hammering the file on every init for resumed sessions.
|
|
878
|
-
if (p.sessionId && p.sessionId !== this.lastSessionId) {
|
|
879
|
-
this.lastSessionId = p.sessionId
|
|
880
|
-
feishu.bindSessionResume(this.sessionName, p.sessionId)
|
|
881
|
-
}
|
|
882
|
-
this.initCount++
|
|
883
|
-
log(`session "${this.sessionName}": SDK init#${this.initCount} pendingCount=${this.pendingUserMessageCount} midBuffer=${this.pendingMidTurnMsgs.length} currentTurn=${this.currentTurn ? 'yes' : 'no'} openingTurn=${this.openingTurn}`)
|
|
884
|
-
|
|
885
|
-
// Boot init (initCount === 1) is claimed by `onUserMessage`'s
|
|
886
|
-
// eager-open path — if a user message landed before the init
|
|
887
|
-
// arrived, it sits in `pendingUserMessageCount` and we drain it
|
|
888
|
-
// below; otherwise the init opens nothing. Subsequent inits
|
|
889
|
-
// (initCount >= 2) mark the start of an SDK-initiated turn:
|
|
890
|
-
// either the SDK is draining the type-ahead queue we fed it via
|
|
891
|
-
// `sendUserText` (isUserBatch), or it's a CronCreate /
|
|
892
|
-
// ScheduleWakeup fire from idle (isScheduledFire).
|
|
893
|
-
//
|
|
894
|
-
// SDK-driven rotation puts the boundary HERE: the previous
|
|
895
|
-
// turn's `result` already closed the in-flight card with
|
|
896
|
-
// `📨 转交新卡` (because pendingUserMessageCount > 0). Now we
|
|
897
|
-
// open a fresh card whose top panel shows the queued messages.
|
|
898
|
-
// currentTurn should be null at this point (result null'd it);
|
|
899
|
-
// the openingTurn guard catches the eager-open vs init race.
|
|
900
|
-
if (this.currentTurn || this.openingTurn) return
|
|
901
|
-
const isUserBatch = this.pendingUserMessageCount > 0
|
|
902
|
-
const isScheduledFire = !isUserBatch && this.initCount > 1
|
|
903
|
-
if (!isUserBatch && !isScheduledFire) return
|
|
904
|
-
const userOpenId = isUserBatch ? this.lastUserOpenId : ''
|
|
905
|
-
if (isUserBatch) {
|
|
906
|
-
this.pendingUserMessageCount = 0
|
|
907
|
-
// Inherit the queued reaction_ids — this turn is collectively
|
|
908
|
-
// responsible for releasing their OneSecond reactions when it
|
|
909
|
-
// closes (via deleteReaction in closeTurnCard).
|
|
910
|
-
this.currentBatchReactionIds = this.pendingReactionIds
|
|
911
|
-
this.pendingReactionIds = new Map()
|
|
912
|
-
}
|
|
913
|
-
this.openingTurn = true
|
|
914
|
-
void (async () => {
|
|
915
|
-
try {
|
|
916
|
-
await this.openTurnCard(userOpenId, isUserBatch ? 'user_message' : 'scheduled')
|
|
917
|
-
if (!this.currentTurn) {
|
|
918
|
-
// SDK already started this turn (its `init` is what got us
|
|
919
|
-
// here) but we have no card to render into. Interrupt so
|
|
920
|
-
// assistant/tool events aren't silently dropped while the
|
|
921
|
-
// model burns tokens. Release the reactions this batch
|
|
922
|
-
// inherited (init handler moved them above) — otherwise
|
|
923
|
-
// they stay ⏳ forever on the user's chat messages.
|
|
924
|
-
log(`session "${this.sessionName}": init-path openTurnCard failed — sendInterrupt + release reactions`)
|
|
925
|
-
this.proc?.sendInterrupt()
|
|
926
|
-
this.releaseAllReactions()
|
|
927
|
-
} else {
|
|
928
|
-
this.status = 'working'
|
|
929
|
-
}
|
|
930
|
-
} finally {
|
|
931
|
-
this.openingTurn = false
|
|
932
|
-
}
|
|
933
|
-
})()
|
|
934
|
-
})
|
|
935
|
-
p.on('assistant_text', ({ text }: { text: string }) => {
|
|
936
|
-
this.appendAssistant(text)
|
|
937
|
-
})
|
|
938
|
-
p.on('thinking', ({ text }: { text: string }) => {
|
|
939
|
-
this.appendThinking(text)
|
|
940
|
-
})
|
|
941
|
-
p.on('tool_use', ({ id, name, input }: { id: string; name: string; input: any }) => {
|
|
942
|
-
this.addTool(id, name, input)
|
|
943
|
-
})
|
|
944
|
-
p.on('tool_result', ({ tool_use_id, content, is_error }: any) => {
|
|
945
|
-
this.completeTool(tool_use_id, content, is_error)
|
|
946
|
-
})
|
|
947
|
-
p.on('can_use_tool', (req: CanUseToolRequest) => {
|
|
948
|
-
this.renderPermission(req)
|
|
949
|
-
})
|
|
950
|
-
p.on('hook_callback', (req: HookCallbackRequest) => {
|
|
951
|
-
// No hooks registered → fail-safe ack.
|
|
952
|
-
this.proc?.sendHookResponse(req.request_id, {})
|
|
953
|
-
})
|
|
954
|
-
p.on('result', () => {
|
|
955
|
-
this.accumulateResultStats()
|
|
956
|
-
// Daemon-driven rotation: mid-turn msgs were buffered (not yet
|
|
957
|
-
// sent to SDK) — close the in-flight card with `📨 转交新卡` and
|
|
958
|
-
// drain the buffer in one shot. The drain writes each buffered
|
|
959
|
-
// msg to SDK stdin, which is the `priority="now"` wake the SDK
|
|
960
|
-
// polling loop needs (claude-code issue #39632) AND constitutes
|
|
961
|
-
// the input for the new batch turn. We open the new card here
|
|
962
|
-
// ourselves rather than waiting on init — the SDK init for this
|
|
963
|
-
// batch will fire shortly but `currentTurn` will already be set,
|
|
964
|
-
// so the init handler will return without double-opening.
|
|
965
|
-
const hasMidTurn = this.pendingMidTurnMsgs.length > 0
|
|
966
|
-
const suffix = hasMidTurn ? '📨 转交新卡' : undefined
|
|
967
|
-
log(`session "${this.sessionName}": SDK result midBuffer=${this.pendingMidTurnMsgs.length} suffix=${suffix ?? '<✅>'}`)
|
|
968
|
-
void this.closeTurnCard(suffix)
|
|
969
|
-
this.status = 'idle'
|
|
970
|
-
if (hasMidTurn) void this.drainMidTurnAndOpen()
|
|
971
|
-
})
|
|
972
|
-
p.on('exit', ({ code, signal, expected }: any) => {
|
|
973
|
-
log(`session "${this.sessionName}": claude exited code=${code} signal=${signal} expected=${expected}`)
|
|
974
|
-
this.proc = null
|
|
975
|
-
this.currentTurn = null
|
|
976
|
-
this.pendingUserMessageCount = 0
|
|
977
|
-
this.pendingMidTurnMsgs = []
|
|
978
|
-
this.lastUserOpenId = ''
|
|
979
|
-
this.releaseAllReactions()
|
|
980
|
-
this.initCount = 0
|
|
981
|
-
this.openingTurn = false
|
|
982
|
-
this.status = 'stopped'
|
|
983
|
-
if (!expected && code !== 0 && signal !== 'SIGTERM') {
|
|
984
|
-
void feishu.sendText(this.chatId, `⚠️ Claude 异常退出 (code=${code}, signal=${signal})。回复任意消息将重新启动。`)
|
|
985
|
-
}
|
|
986
|
-
})
|
|
987
|
-
}
|
|
988
|
-
|
|
989
|
-
/** Pull per-turn numbers off `proc.lastResult` (set by ClaudeProcess when
|
|
990
|
-
* the `result` message landed) and roll them into cumStats + the
|
|
991
|
-
* "上一轮" delta. Called exactly once per result event, right before
|
|
992
|
-
* closeTurnCard. */
|
|
993
|
-
private accumulateResultStats(): void {
|
|
994
|
-
const r = this.proc?.lastResult
|
|
995
|
-
if (!r) return
|
|
996
|
-
const u = r.usage ?? {}
|
|
997
|
-
const inputTokens = (u.input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0) + (u.cache_read_input_tokens ?? 0)
|
|
998
|
-
const outputTokens = u.output_tokens ?? 0
|
|
999
|
-
const tokens = inputTokens + outputTokens
|
|
1000
|
-
const costUsd = r.cost_usd ?? 0
|
|
1001
|
-
const durationMs = r.duration_ms ?? 0
|
|
1002
|
-
this.cumStats.tokens += tokens
|
|
1003
|
-
this.cumStats.costUsd += costUsd
|
|
1004
|
-
this.cumStats.turns += r.num_turns ?? 1
|
|
1005
|
-
this.lastTurnDelta = { tokens, costUsd, durationMs, inputTokens }
|
|
1006
|
-
}
|
|
1007
|
-
|
|
1008
|
-
/** Current context-window occupancy estimate — uses the most recent
|
|
1009
|
-
* assistant `usage` (input + caches), since each assistant reply replays
|
|
1010
|
-
* the full conversation. Returns 0 when no per-call usage is available
|
|
1011
|
-
* (process dead, or fresh spawn before first assistant message);
|
|
1012
|
-
* `lastTurnDelta.inputTokens` is the CUMULATIVE turn input across all
|
|
1013
|
-
* API calls in the turn (sum of cache_read across N steps) — using it
|
|
1014
|
-
* here would inflate the percentage by Nx after a heavy multi-step
|
|
1015
|
-
* turn (observed bug 2026-05-16: 417% in the `hi` panel after killing
|
|
1016
|
-
* the proc with a long turn's delta still on file). */
|
|
1017
|
-
private currentContextTokens(): number {
|
|
1018
|
-
const u = this.proc?.lastUsage as ClaudeUsage | null | undefined
|
|
1019
|
-
if (!u) return 0
|
|
1020
|
-
return (u.input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0) + (u.cache_read_input_tokens ?? 0)
|
|
1021
|
-
}
|
|
1022
|
-
|
|
1023
|
-
/** Context-window capacity for the model the subprocess is currently
|
|
1024
|
-
* running — sourced authoritatively from `result.modelUsage[model]
|
|
1025
|
-
* .contextWindow` captured by ClaudeProcess on each turn close, so
|
|
1026
|
-
* the daemon doesn't have to enumerate model ids itself (was the
|
|
1027
|
-
* source of a "560K/200K" display bug — model id didn't include
|
|
1028
|
-
* `[1m]` so the hardcoded fallback won). */
|
|
1029
|
-
private contextWindowMax(): number {
|
|
1030
|
-
return this.proc?.lastContextWindow ?? 200_000
|
|
1031
|
-
}
|
|
1032
|
-
|
|
1033
|
-
/** Drain `pendingMidTurnMsgs` to the SDK and open a fresh card for the
|
|
1034
|
-
* resulting batch turn. Called from the `result` handler when buffered
|
|
1035
|
-
* mid-turn messages need to start their own turn. The `sendUserText`
|
|
1036
|
-
* calls wake the SDK polling loop (priority="now" semantics) and
|
|
1037
|
-
* comprise the input for the new turn. Opens the card here rather
|
|
1038
|
-
* than deferring to init because the init for this batch will arrive
|
|
1039
|
-
* with `currentTurn` already set and bail.
|
|
1040
|
-
*
|
|
1041
|
-
* Each sendUserText also bumps `pendingUserMessageCount`. The SDK
|
|
1042
|
-
* USUALLY collapses our N writes into one merged turn, but **not
|
|
1043
|
-
* always** — empirically observed 2026-05-17, test1 accumulator
|
|
1044
|
-
* session: when the first write lands in an idle SDK (turn just
|
|
1045
|
-
* ended), the SDK eagerly starts a turn for that msg alone, then
|
|
1046
|
-
* merges the rest into a second turn. Without the bump here, that
|
|
1047
|
-
* second turn fires an `init` with `pendingUserMessageCount === 0`
|
|
1048
|
-
* and the init handler misclassifies it as a scheduled wakeup,
|
|
1049
|
-
* painting the `⏰ 触发` banner on what is really a user batch. */
|
|
1050
|
-
private async drainMidTurnAndOpen(): Promise<void> {
|
|
1051
|
-
if (this.pendingMidTurnMsgs.length === 0) return
|
|
1052
|
-
const batch = this.pendingMidTurnMsgs
|
|
1053
|
-
this.pendingMidTurnMsgs = []
|
|
1054
|
-
this.openingTurn = true
|
|
1055
|
-
try {
|
|
1056
|
-
for (const msg of batch) {
|
|
1057
|
-
this.proc!.sendUserText(msg.wireText, msg.files)
|
|
1058
|
-
this.pendingUserMessageCount++
|
|
1059
|
-
if (msg.msgId) {
|
|
1060
|
-
const rid = this.pendingReactionIds.get(msg.msgId) ?? ''
|
|
1061
|
-
this.currentBatchReactionIds.set(msg.msgId, rid)
|
|
1062
|
-
this.pendingReactionIds.delete(msg.msgId)
|
|
1063
|
-
}
|
|
1064
|
-
}
|
|
1065
|
-
const last = batch[batch.length - 1]
|
|
1066
|
-
const userOpenId = last?.userOpenId ?? this.lastUserOpenId
|
|
1067
|
-
await this.openTurnCard(userOpenId, 'user_message')
|
|
1068
|
-
this.status = 'working'
|
|
1069
|
-
} finally {
|
|
1070
|
-
this.openingTurn = false
|
|
1071
|
-
}
|
|
1072
|
-
}
|
|
1073
|
-
|
|
1074
|
-
private async openTurnCard(userOpenId: string, trigger: 'user_message' | 'scheduled'): Promise<void> {
|
|
1075
|
-
const turn = ++this.turnCounter
|
|
1076
|
-
log(`session "${this.sessionName}": openTurnCard turn=${turn} trigger=${trigger}`)
|
|
1077
|
-
const card = cards.mainConversationCard({
|
|
1078
|
-
sessionName: this.sessionName,
|
|
1079
|
-
turn,
|
|
1080
|
-
effort: 'max',
|
|
1081
|
-
kind: trigger,
|
|
1082
|
-
})
|
|
1083
|
-
const messageId = await feishu.sendCard(this.chatId, card)
|
|
1084
|
-
if (!messageId) {
|
|
1085
|
-
log(`session "${this.sessionName}": openTurnCard sendCard EXHAUSTED retries — surfacing via raw text`)
|
|
1086
|
-
// sendCard already retried 3× through the SDK. If it still came back
|
|
1087
|
-
// null we're either on a sustained SDK-axios outage or a Feishu
|
|
1088
|
-
// business reject. Either way the user just sent us a message and
|
|
1089
|
-
// it's gone into a black hole — surface that explicitly so they
|
|
1090
|
-
// know to resend instead of waiting for a reply that won't come.
|
|
1091
|
-
// Use raw fetch (not sendText) because if the SDK is the broken
|
|
1092
|
-
// thing we'd be doomed to silence otherwise.
|
|
1093
|
-
await feishu.sendTextRaw(
|
|
1094
|
-
this.chatId,
|
|
1095
|
-
'❌ 创建对话卡片失败 (Feishu SDK 重试 3 次后仍连不上)。你这条消息没能送到 Claude,请稍后重发。',
|
|
1096
|
-
)
|
|
1097
|
-
// currentTurn left null as the failure signal. Caller decides
|
|
1098
|
-
// whether to sendInterrupt: onUserMessage's eager-open path
|
|
1099
|
-
// hasn't fed SDK yet so doesn't need to; the init handler has
|
|
1100
|
-
// (SDK started the turn itself) and must.
|
|
1101
|
-
return
|
|
1102
|
-
}
|
|
1103
|
-
let cardId: string
|
|
1104
|
-
try { cardId = await cardkit.convertMessageToCard(messageId) }
|
|
1105
|
-
catch (e) { log(`session "${this.sessionName}": id_convert failed: ${e}`); return }
|
|
1106
|
-
this.currentTurn = {
|
|
1107
|
-
cardId,
|
|
1108
|
-
messageId,
|
|
1109
|
-
userOpenId,
|
|
1110
|
-
trigger,
|
|
1111
|
-
thinkingText: '',
|
|
1112
|
-
toolCount: 0,
|
|
1113
|
-
toolByUseId: new Map(),
|
|
1114
|
-
readBatches: new Map(),
|
|
1115
|
-
openReadBatchI: null,
|
|
1116
|
-
assistantSegmentCount: 0,
|
|
1117
|
-
currentAssistantSegmentId: null,
|
|
1118
|
-
currentAssistantText: '',
|
|
1119
|
-
segmentTexts: new Map(),
|
|
1120
|
-
startedAt: Date.now(),
|
|
1121
|
-
}
|
|
1122
|
-
}
|
|
1123
|
-
|
|
1124
|
-
// Stream-event handlers are intentionally SYNCHRONOUS. Every cardkit op
|
|
1125
|
-
// is queued (per-card Promise chain in cardkit.ts), so we fire-and-
|
|
1126
|
-
// forget here and rely on enqueue source order — that way no `await`
|
|
1127
|
-
// can yield mid-handler and let `closeTurnCard` (or another event) race
|
|
1128
|
-
// and mutate `this.currentTurn` underfoot.
|
|
1129
|
-
private appendAssistant(delta: string): void {
|
|
1130
|
-
if (!this.currentTurn) return
|
|
1131
|
-
const turn = this.currentTurn
|
|
1132
|
-
if (!turn.currentAssistantSegmentId) {
|
|
1133
|
-
// New assistant segment opens a visual break — any prior Read run
|
|
1134
|
-
// is now visually separated from future Reads, so close the batch
|
|
1135
|
-
// window. Future Reads will start a fresh batch at a new i.
|
|
1136
|
-
turn.openReadBatchI = null
|
|
1137
|
-
const i = turn.assistantSegmentCount++
|
|
1138
|
-
const segId = cards.ELEMENTS.assistant(i)
|
|
1139
|
-
turn.currentAssistantSegmentId = segId
|
|
1140
|
-
turn.currentAssistantText = ''
|
|
1141
|
-
void cardkit.addElement(turn.cardId, cards.assistantSegmentElement(i), {
|
|
1142
|
-
type: 'insert_before', targetElementId: cards.ELEMENTS.footer,
|
|
1143
|
-
}, () => {
|
|
1144
|
-
// addElement永久失败:reset segmentId 让下次 delta 重新创建
|
|
1145
|
-
// segment,否则后续 streamText 全都 PUT 到不存在的 element,
|
|
1146
|
-
// 整段 assistant text 在用户那看不到。守 segId 不变以防 turn
|
|
1147
|
-
// rotation 或 addTool 已经清掉了它(每次 addElement 闭包带的
|
|
1148
|
-
// 是自己创建那次的 segId,只清自己的)。
|
|
1149
|
-
if (turn.currentAssistantSegmentId === segId) {
|
|
1150
|
-
log(`session "${this.sessionName}": assistant segment ${segId} addElement failed — will retry on next delta`)
|
|
1151
|
-
turn.currentAssistantSegmentId = null
|
|
1152
|
-
turn.currentAssistantText = ''
|
|
1153
|
-
turn.segmentTexts.delete(segId)
|
|
1154
|
-
}
|
|
1155
|
-
})
|
|
1156
|
-
}
|
|
1157
|
-
turn.currentAssistantText += delta
|
|
1158
|
-
const segId = turn.currentAssistantSegmentId
|
|
1159
|
-
if (!segId) return // addElement 已失败 reset,等下一次 delta 重建
|
|
1160
|
-
turn.segmentTexts.set(segId, turn.currentAssistantText)
|
|
1161
|
-
cardkit.streamTextThrottled(turn.cardId, segId, turn.currentAssistantText)
|
|
1162
|
-
// Chat-list preview: tail of the latest assistant text. Feishu
|
|
1163
|
-
// truncates anyway; ~60 chars is what shows on a typical phone
|
|
1164
|
-
// preview line. patchSummaryThrottled is rate-limited on its own.
|
|
1165
|
-
const tail = turn.currentAssistantText.slice(-60)
|
|
1166
|
-
cardkit.patchSummaryThrottled(turn.cardId, tail)
|
|
1167
|
-
}
|
|
1168
|
-
|
|
1169
|
-
private appendThinking(delta: string): void {
|
|
1170
|
-
if (!this.currentTurn) return
|
|
1171
|
-
this.currentTurn.thinkingText += delta
|
|
1172
|
-
cardkit.streamTextThrottled(
|
|
1173
|
-
this.currentTurn.cardId,
|
|
1174
|
-
cards.ELEMENTS.thinking,
|
|
1175
|
-
this.currentTurn.thinkingText,
|
|
1176
|
-
)
|
|
1177
|
-
}
|
|
1178
|
-
|
|
1179
|
-
private isTaskWorkflow(name: string): boolean {
|
|
1180
|
-
return name.startsWith('Task') && name !== 'Task'
|
|
1181
|
-
}
|
|
1182
|
-
|
|
1183
|
-
private todosArray(): cards.Todo[] {
|
|
1184
|
-
return [...this.currentTodos.values()]
|
|
1185
|
-
}
|
|
1186
|
-
|
|
1187
|
-
private addTool(toolUseId: string, name: string, input: any): void {
|
|
1188
|
-
if (!this.currentTurn) return
|
|
1189
|
-
// Close current assistant segment (if any) so the tool panel renders
|
|
1190
|
-
// AFTER it in card body order. Flush queues the segment's last
|
|
1191
|
-
// buffered delta before the tool element is inserted.
|
|
1192
|
-
if (this.currentTurn.currentAssistantSegmentId) {
|
|
1193
|
-
void cardkit.flush(this.currentTurn.cardId)
|
|
1194
|
-
this.currentTurn.currentAssistantSegmentId = null
|
|
1195
|
-
this.currentTurn.currentAssistantText = ''
|
|
1196
|
-
}
|
|
1197
|
-
// Consecutive Read merger: if a Read run is already open, append to
|
|
1198
|
-
// its batch and re-render the panel instead of inserting a new one.
|
|
1199
|
-
// Any other tool name closes the run (handled below).
|
|
1200
|
-
if (name === 'Read' && this.currentTurn.openReadBatchI !== null) {
|
|
1201
|
-
const batchI = this.currentTurn.openReadBatchI
|
|
1202
|
-
const batch = this.currentTurn.readBatches.get(batchI)!
|
|
1203
|
-
const slot = batch.items.length
|
|
1204
|
-
batch.items.push({ toolUseId, input, output: null, isError: false })
|
|
1205
|
-
this.currentTurn.toolByUseId.set(toolUseId, { i: batchI, name, input, readBatchSlot: slot })
|
|
1206
|
-
const el = cards.readBatchElement(batchI, batch.items)
|
|
1207
|
-
void cardkit.replaceElement(this.currentTurn.cardId, cards.ELEMENTS.tool(batchI), el)
|
|
1208
|
-
return
|
|
1209
|
-
}
|
|
1210
|
-
if (name !== 'Read') this.currentTurn.openReadBatchI = null
|
|
1211
|
-
const i = this.currentTurn.toolCount++
|
|
1212
|
-
if (name === 'Read') {
|
|
1213
|
-
// First Read of a potential run — render the existing single-tool
|
|
1214
|
-
// panel (which keeps the full file-contents dump on completion). If
|
|
1215
|
-
// a second Read arrives, completeTool/addTool will switch it to
|
|
1216
|
-
// `readBatchElement`.
|
|
1217
|
-
this.currentTurn.openReadBatchI = i
|
|
1218
|
-
this.currentTurn.readBatches.set(i, {
|
|
1219
|
-
items: [{ toolUseId, input, output: null, isError: false }],
|
|
1220
|
-
})
|
|
1221
|
-
this.currentTurn.toolByUseId.set(toolUseId, { i, name, input, readBatchSlot: 0 })
|
|
1222
|
-
const el = cards.toolCallElement(i, name, input, null, '⏳', undefined, undefined)
|
|
1223
|
-
void cardkit.addElement(this.currentTurn.cardId, el, {
|
|
1224
|
-
type: 'insert_before', targetElementId: cards.ELEMENTS.footer,
|
|
1225
|
-
})
|
|
1226
|
-
return
|
|
1227
|
-
}
|
|
1228
|
-
this.currentTurn.toolByUseId.set(toolUseId, { i, name, input })
|
|
1229
|
-
// AskUserQuestion is a client-side tool — daemon renders the choice
|
|
1230
|
-
// UI in-line and supplies the tool_result itself once the user
|
|
1231
|
-
// clicks. Branch BEFORE the generic toolCallElement so we never
|
|
1232
|
-
// fall through to a JSON dump or, worse, get clobbered by the
|
|
1233
|
-
// permission flow (which would render 🔐 three-button buttons that
|
|
1234
|
-
// don't match the actual N options).
|
|
1235
|
-
if (name === 'AskUserQuestion') {
|
|
1236
|
-
const questions = Array.isArray(input?.questions) ? input.questions as cards.AskQuestion[] : []
|
|
1237
|
-
const startIdx = questions.length > 0 ? 0 : undefined
|
|
1238
|
-
const answered = new Map<number, cards.AskAnswered>()
|
|
1239
|
-
this.pendingAsks.set(toolUseId, {
|
|
1240
|
-
questions,
|
|
1241
|
-
i,
|
|
1242
|
-
answers: {},
|
|
1243
|
-
answered,
|
|
1244
|
-
currentIdx: startIdx,
|
|
1245
|
-
})
|
|
1246
|
-
const el = cards.askUserQuestionElement(i, toolUseId, questions, '🤔', {
|
|
1247
|
-
currentIdx: startIdx,
|
|
1248
|
-
answered,
|
|
1249
|
-
})
|
|
1250
|
-
void cardkit.addElement(this.currentTurn.cardId, el, {
|
|
1251
|
-
type: 'insert_before',
|
|
1252
|
-
targetElementId: cards.ELEMENTS.footer,
|
|
1253
|
-
})
|
|
1254
|
-
// Phone push — user has to come back and answer before Claude can
|
|
1255
|
-
// continue. Set summary to the question text so the lock-screen
|
|
1256
|
-
// notification preview shows what the user needs to answer.
|
|
1257
|
-
if (this.currentTurn.userOpenId && this.currentTurn.messageId) {
|
|
1258
|
-
const turn = this.currentTurn
|
|
1259
|
-
const q0 = questions[0]?.question?.trim() ?? ''
|
|
1260
|
-
const truncated = q0.length > 40 ? q0.slice(0, 40) + '…' : q0
|
|
1261
|
-
const summary = questions.length > 1
|
|
1262
|
-
? `❓ 待回答 ${questions.length} 题${truncated ? `: ${truncated}` : ''}`
|
|
1263
|
-
: truncated
|
|
1264
|
-
? `❓ ${truncated}`
|
|
1265
|
-
: '❓ 等你回答问题'
|
|
1266
|
-
void (async () => {
|
|
1267
|
-
await this.setUrgentSummary(turn.cardId, summary)
|
|
1268
|
-
await feishu.urgentApp(turn.messageId, [turn.userOpenId])
|
|
1269
|
-
})()
|
|
1270
|
-
}
|
|
1271
|
-
return
|
|
1272
|
-
}
|
|
1273
|
-
// Pending Task* panels still show the *pre-op* todo mirror so users
|
|
1274
|
-
// can read the current state immediately, without waiting for the
|
|
1275
|
-
// tool to return.
|
|
1276
|
-
const todos = this.isTaskWorkflow(name) ? this.todosArray() : undefined
|
|
1277
|
-
const el = cards.toolCallElement(i, name, input, null, '⏳', undefined, todos)
|
|
1278
|
-
void cardkit.addElement(this.currentTurn.cardId, el, {
|
|
1279
|
-
type: 'insert_before',
|
|
1280
|
-
targetElementId: cards.ELEMENTS.footer,
|
|
1281
|
-
})
|
|
1282
|
-
}
|
|
1283
|
-
|
|
1284
|
-
private completeTool(toolUseId: string, content: any, isError: boolean): void {
|
|
1285
|
-
if (!this.currentTurn) return
|
|
1286
|
-
const meta = this.currentTurn.toolByUseId.get(toolUseId)
|
|
1287
|
-
if (!meta) return
|
|
1288
|
-
const output = typeof content === 'string'
|
|
1289
|
-
? content
|
|
1290
|
-
: Array.isArray(content)
|
|
1291
|
-
? content.map((c: any) => c?.text ?? JSON.stringify(c)).join('\n')
|
|
1292
|
-
: JSON.stringify(content)
|
|
1293
|
-
// Stash on the meta — every Task* op coming after this point may
|
|
1294
|
-
// need to re-render this panel with a fresher todo footer, so we
|
|
1295
|
-
// can't discard the output after the first paint.
|
|
1296
|
-
meta.output = output
|
|
1297
|
-
meta.isError = isError
|
|
1298
|
-
// AskUserQuestion already had its final panel painted by resolveAsk
|
|
1299
|
-
// (✅ + the chosen option marked, others dimmed). The tool_result
|
|
1300
|
-
// arriving here is just the SDK's synthesised echo — re-rendering
|
|
1301
|
-
// via toolCallElement would clobber the nice option-row layout
|
|
1302
|
-
// with a generic JSON dump. Bail out; the panel is done.
|
|
1303
|
-
if (meta.name === 'AskUserQuestion') return
|
|
1304
|
-
// Read batch path: update this row's status in the shared batch then
|
|
1305
|
-
// re-render. Single-item batches keep the original full-output panel
|
|
1306
|
-
// (file-contents dump); 2+ items switch to the compact `Read · N 次`
|
|
1307
|
-
// listing, which overwrites whatever was last drawn at this i.
|
|
1308
|
-
if (meta.name === 'Read' && meta.readBatchSlot != null) {
|
|
1309
|
-
const batch = this.currentTurn.readBatches.get(meta.i)
|
|
1310
|
-
if (batch) {
|
|
1311
|
-
const row = batch.items[meta.readBatchSlot]
|
|
1312
|
-
if (row) { row.output = output; row.isError = isError }
|
|
1313
|
-
const el = batch.items.length >= 2
|
|
1314
|
-
? cards.readBatchElement(meta.i, batch.items)
|
|
1315
|
-
: cards.toolCallElement(meta.i, meta.name, meta.input, output, isError ? '❌' : '✅', meta.resolvedNote, undefined)
|
|
1316
|
-
void cardkit.replaceElement(this.currentTurn.cardId, cards.ELEMENTS.tool(meta.i), el)
|
|
1317
|
-
}
|
|
1318
|
-
return
|
|
1319
|
-
}
|
|
1320
|
-
// Update the local todo mirror BEFORE rendering so the just-
|
|
1321
|
-
// completed panel shows the new state too (e.g. a TaskCreate panel
|
|
1322
|
-
// already lists the task it just created).
|
|
1323
|
-
if (!isError && this.isTaskWorkflow(meta.name)) {
|
|
1324
|
-
this.updateTodosFromTask(meta.name, meta.input, output)
|
|
1325
|
-
}
|
|
1326
|
-
const todos = this.isTaskWorkflow(meta.name) ? this.todosArray() : undefined
|
|
1327
|
-
const el = cards.toolCallElement(meta.i, meta.name, meta.input, output, isError ? '❌' : '✅', meta.resolvedNote, todos)
|
|
1328
|
-
void cardkit.replaceElement(this.currentTurn.cardId, cards.ELEMENTS.tool(meta.i), el)
|
|
1329
|
-
// Cascade the new mirror into every prior Task* panel in this turn
|
|
1330
|
-
// so any expanded panel reflects the latest state, not the snapshot
|
|
1331
|
-
// captured when that op ran.
|
|
1332
|
-
if (!isError && this.isTaskWorkflow(meta.name)) {
|
|
1333
|
-
this.refreshOtherTaskPanels(toolUseId)
|
|
1334
|
-
}
|
|
1335
|
-
}
|
|
1336
|
-
|
|
1337
|
-
/** Roll a single Task* op into the local mirror — best-effort. Output
|
|
1338
|
-
* parsing is regex-based (the SDK returns plain text like "Task #7
|
|
1339
|
-
* created successfully: …"), so unexpected variants are skipped
|
|
1340
|
-
* silently rather than blowing up the panel render. */
|
|
1341
|
-
private updateTodosFromTask(name: string, input: any, output: string): void {
|
|
1342
|
-
switch (name) {
|
|
1343
|
-
case 'TaskCreate': {
|
|
1344
|
-
const m = output.match(/Task #(\d+) created/)
|
|
1345
|
-
if (!m) return
|
|
1346
|
-
const id = Number(m[1])
|
|
1347
|
-
this.currentTodos.set(id, {
|
|
1348
|
-
id,
|
|
1349
|
-
subject: input.subject,
|
|
1350
|
-
description: input.description,
|
|
1351
|
-
activeForm: input.activeForm,
|
|
1352
|
-
status: 'pending',
|
|
1353
|
-
})
|
|
1354
|
-
return
|
|
1355
|
-
}
|
|
1356
|
-
case 'TaskUpdate': {
|
|
1357
|
-
const id = Number(input.taskId)
|
|
1358
|
-
if (!Number.isFinite(id)) return
|
|
1359
|
-
// status=deleted is the SDK's tombstone — drop from the mirror
|
|
1360
|
-
// so the readout doesn't carry it forever. Server still keeps
|
|
1361
|
-
// it; the mirror is just for the panel footer.
|
|
1362
|
-
if (input.status === 'deleted') { this.currentTodos.delete(id); return }
|
|
1363
|
-
const cur = this.currentTodos.get(id) ?? { id, status: 'pending' as const }
|
|
1364
|
-
if (input.status) cur.status = input.status
|
|
1365
|
-
if (input.subject) cur.subject = input.subject
|
|
1366
|
-
if (input.description) cur.description = input.description
|
|
1367
|
-
if (input.owner) cur.owner = input.owner
|
|
1368
|
-
if (input.activeForm) cur.activeForm = input.activeForm
|
|
1369
|
-
this.currentTodos.set(id, cur)
|
|
1370
|
-
return
|
|
1371
|
-
}
|
|
1372
|
-
// TaskList / TaskGet / TaskStop / TaskOutput / TaskDelete:
|
|
1373
|
-
// read-only or parse-heavy — skip mirror update. The panel will
|
|
1374
|
-
// still render the SDK's textual result below the operation
|
|
1375
|
-
// block, which is enough to disambiguate.
|
|
1376
|
-
}
|
|
1377
|
-
}
|
|
1378
|
-
|
|
1379
|
-
/** Re-render every Task* panel in the current turn (except the one
|
|
1380
|
-
* that just landed — already up-to-date) so they all show the latest
|
|
1381
|
-
* todo mirror in their footers. Cheap: ELEMENTS.tool(i) replace is
|
|
1382
|
-
* queued through the per-card Promise chain like any other op. */
|
|
1383
|
-
private refreshOtherTaskPanels(skipToolUseId: string): void {
|
|
1384
|
-
if (!this.currentTurn) return
|
|
1385
|
-
const todos = this.todosArray()
|
|
1386
|
-
for (const [id, meta] of this.currentTurn.toolByUseId) {
|
|
1387
|
-
if (id === skipToolUseId) continue
|
|
1388
|
-
if (!this.isTaskWorkflow(meta.name)) continue
|
|
1389
|
-
const status: '⏳' | '✅' | '❌' = meta.output === undefined
|
|
1390
|
-
? '⏳'
|
|
1391
|
-
: (meta.isError ? '❌' : '✅')
|
|
1392
|
-
const el = cards.toolCallElement(
|
|
1393
|
-
meta.i, meta.name, meta.input, meta.output ?? null,
|
|
1394
|
-
status, meta.resolvedNote, todos,
|
|
1395
|
-
)
|
|
1396
|
-
void cardkit.replaceElement(this.currentTurn.cardId, cards.ELEMENTS.tool(meta.i), el)
|
|
1397
|
-
}
|
|
1398
|
-
}
|
|
1399
|
-
|
|
1400
|
-
/** Merge the permission ask into the existing tool element in the
|
|
1401
|
-
* current turn card. The user sees one continuous timeline: ⏳ pending
|
|
1402
|
-
* → 🔐 awaiting approval (with buttons) → ⏳ allowed / ❌ denied → ✅
|
|
1403
|
-
* with output. No floating orange card.
|
|
1404
|
-
*
|
|
1405
|
-
* `tool_use` is emitted as part of the assistant message and lands on
|
|
1406
|
-
* our `addTool` handler BEFORE the SDK's `can_use_tool` control_request
|
|
1407
|
-
* arrives — so by the time we get here, `toolByUseId` already has the
|
|
1408
|
-
* entry we need to replace.
|
|
1409
|
-
*
|
|
1410
|
-
* Edge cases (no current turn / missing tool_use_id / unknown id) are
|
|
1411
|
-
* surfaced loudly and auto-denied. We don't fall back to a standalone
|
|
1412
|
-
* card — per the project's no-fallbacks rule, hidden anomalies are
|
|
1413
|
-
* worse than visible deny errors. */
|
|
1414
|
-
private renderPermission(req: CanUseToolRequest): void {
|
|
1415
|
-
const turn = this.currentTurn
|
|
1416
|
-
if (!turn) {
|
|
1417
|
-
log(`session "${this.sessionName}": can_use_tool with no current turn — auto-deny req=${req.request_id}`)
|
|
1418
|
-
this.proc?.sendPermissionResponse(req.request_id, 'deny', { denyMessage: 'no active turn' })
|
|
1419
|
-
return
|
|
1420
|
-
}
|
|
1421
|
-
const toolUseId = req.tool_use_id
|
|
1422
|
-
if (!toolUseId) {
|
|
1423
|
-
log(`session "${this.sessionName}": can_use_tool without tool_use_id — auto-deny req=${req.request_id}`)
|
|
1424
|
-
this.proc?.sendPermissionResponse(req.request_id, 'deny', { denyMessage: 'no tool_use_id' })
|
|
1425
|
-
return
|
|
1426
|
-
}
|
|
1427
|
-
const meta = turn.toolByUseId.get(toolUseId)
|
|
1428
|
-
if (!meta) {
|
|
1429
|
-
log(`session "${this.sessionName}": can_use_tool for unknown tool_use_id=${toolUseId} — auto-deny req=${req.request_id}`)
|
|
1430
|
-
this.proc?.sendPermissionResponse(req.request_id, 'deny', { denyMessage: 'unknown tool_use_id' })
|
|
1431
|
-
return
|
|
1432
|
-
}
|
|
1433
|
-
// AskUserQuestion: SDK routes it through can_use_tool even under
|
|
1434
|
-
// bypass. The PAYLOAD of "user has answered" is the permission
|
|
1435
|
-
// response itself — specifically `updatedInput.answers`. So we
|
|
1436
|
-
// CANNOT auto-allow here (that's the v0.1.2 bug: SDK got an empty
|
|
1437
|
-
// answers map and immediately synthesised a "User has answered
|
|
1438
|
-
// your questions: ." tool_result). Park the requestId on the
|
|
1439
|
-
// pendingAsk record and wait for the user to click an option;
|
|
1440
|
-
// onAskAnswer will then send allow + updatedInput.answers in one
|
|
1441
|
-
// shot. If the user already clicked between addTool and now —
|
|
1442
|
-
// the deferredAnswer slot — settle immediately.
|
|
1443
|
-
if (meta.name === 'AskUserQuestion') {
|
|
1444
|
-
const ask = this.pendingAsks.get(toolUseId)
|
|
1445
|
-
if (!ask) {
|
|
1446
|
-
log(`session "${this.sessionName}": AskUserQuestion ${toolUseId} missing pendingAsk — deny`)
|
|
1447
|
-
this.proc?.sendPermissionResponse(req.request_id, 'deny', { denyMessage: 'no pending ask' })
|
|
1448
|
-
return
|
|
1449
|
-
}
|
|
1450
|
-
ask.requestId = req.request_id
|
|
1451
|
-
this.pendingPermissions.set(req.request_id, { toolUseId })
|
|
1452
|
-
// Fast-clicker race: the user may have answered every question
|
|
1453
|
-
// while we were still waiting for can_use_tool to arrive. If so,
|
|
1454
|
-
// advanceAsk parked the all-done state and we drain it now.
|
|
1455
|
-
if (ask.currentIdx === undefined) this.finalizeAsk(toolUseId)
|
|
1456
|
-
return
|
|
1457
|
-
}
|
|
1458
|
-
this.status = 'awaiting_permission'
|
|
1459
|
-
this.pendingPermissions.set(req.request_id, { toolUseId })
|
|
1460
|
-
const el = cards.toolCallPermissionElement(meta.i, meta.name, meta.input, req.request_id)
|
|
1461
|
-
void cardkit.replaceElement(turn.cardId, cards.ELEMENTS.tool(meta.i), el)
|
|
1462
|
-
// Phone push — Claude is blocked until the user approves/denies.
|
|
1463
|
-
// Set summary to "🔐 等审批: <tool>(<input summary>)" so the lock-
|
|
1464
|
-
// screen notification shows which tool needs approval.
|
|
1465
|
-
if (turn.userOpenId && turn.messageId) {
|
|
1466
|
-
const inputSummary = cards.summarizeToolInput(meta.name, meta.input)
|
|
1467
|
-
const tail = inputSummary && inputSummary.length > 30
|
|
1468
|
-
? inputSummary.slice(0, 30) + '…'
|
|
1469
|
-
: inputSummary
|
|
1470
|
-
const summary = tail
|
|
1471
|
-
? `🔐 等审批: ${meta.name} · ${tail}`
|
|
1472
|
-
: `🔐 等审批: ${meta.name}`
|
|
1473
|
-
void (async () => {
|
|
1474
|
-
await this.setUrgentSummary(turn.cardId, summary)
|
|
1475
|
-
await feishu.urgentApp(turn.messageId, [turn.userOpenId])
|
|
1476
|
-
})()
|
|
1477
|
-
}
|
|
1478
|
-
}
|
|
1479
|
-
|
|
1480
|
-
private async closeTurnCard(suffix?: string): Promise<void> {
|
|
1481
|
-
// CRITICAL: capture-and-null in a single synchronous block at entry
|
|
1482
|
-
// so a parallel `closeTurnCard` (e.g. result event firing while
|
|
1483
|
-
// onUserMessage is awaiting an interrupt) can't double-process the
|
|
1484
|
-
// same turn — second caller observes null and bails. The promised
|
|
1485
|
-
// sync-handler invariant only protects callers that take the turn
|
|
1486
|
-
// off the table BEFORE their first await.
|
|
1487
|
-
const turn = this.currentTurn
|
|
1488
|
-
if (!turn) return
|
|
1489
|
-
this.currentTurn = null
|
|
1490
|
-
const elapsed = ((Date.now() - turn.startedAt) / 1000).toFixed(1)
|
|
1491
|
-
const cardId = turn.cardId
|
|
1492
|
-
const thinkingText = turn.thinkingText
|
|
1493
|
-
const segmentTexts = turn.segmentTexts
|
|
1494
|
-
await cardkit.flush(cardId)
|
|
1495
|
-
|
|
1496
|
-
// [[send: /abs/path]] markers — strip them from each assistant
|
|
1497
|
-
// segment and collect paths to upload after the card finalizes.
|
|
1498
|
-
const sendPaths: string[] = []
|
|
1499
|
-
for (const [segId, fullText] of segmentTexts) {
|
|
1500
|
-
let changed = false
|
|
1501
|
-
const cleaned = fullText.replace(SEND_MARKER_RE, (_m, p1) => {
|
|
1502
|
-
changed = true
|
|
1503
|
-
const p = String(p1).trim()
|
|
1504
|
-
if (p.startsWith('/')) sendPaths.push(p)
|
|
1505
|
-
else log(`session "${this.sessionName}": ignore non-absolute send path: ${p}`)
|
|
1506
|
-
return ''
|
|
1507
|
-
})
|
|
1508
|
-
if (changed) {
|
|
1509
|
-
const finalText = cleaned.trim() || ' '
|
|
1510
|
-
await cardkit.replaceElement(cardId, segId, {
|
|
1511
|
-
tag: 'markdown', element_id: segId, content: finalText,
|
|
1512
|
-
})
|
|
1513
|
-
}
|
|
1514
|
-
}
|
|
1515
|
-
|
|
1516
|
-
if (thinkingText.trim()) {
|
|
1517
|
-
await cardkit.replaceElement(cardId, cards.ELEMENTS.thinking, cards.thinkingCollapsedPanel(thinkingText))
|
|
1518
|
-
}
|
|
1519
|
-
const sendNote = sendPaths.length ? ` · 📎 ${sendPaths.length}` : ''
|
|
1520
|
-
// State marker leads the footer (✅ for natural completion, or the
|
|
1521
|
-
// suffix verbatim for non-natural states like `🛑 打断`). The
|
|
1522
|
-
// trailing "done" word is gone — the ✅ already carries that
|
|
1523
|
-
// meaning. User-confirmed footer order 2026-05-16.
|
|
1524
|
-
const stateMark = suffix ? suffix : '✅'
|
|
1525
|
-
// Per-turn metrics: context-window occupancy (as a real percentage,
|
|
1526
|
-
// not a token count) and dollar cost. Only meaningful on a clean
|
|
1527
|
-
// close — suffix-tagged turns (interrupt) didn't fire the `result`
|
|
1528
|
-
// event that populates `lastTurnDelta`, so these numbers would be
|
|
1529
|
-
// stale and misleading.
|
|
1530
|
-
let metrics = ''
|
|
1531
|
-
if (!suffix) {
|
|
1532
|
-
const ctxTokens = this.currentContextTokens()
|
|
1533
|
-
const ctxMax = this.contextWindowMax()
|
|
1534
|
-
if (ctxTokens > 0 && ctxMax > 0) {
|
|
1535
|
-
const pct = Math.round((ctxTokens / ctxMax) * 100)
|
|
1536
|
-
metrics += ` · 📊 ${pct}%`
|
|
1537
|
-
}
|
|
1538
|
-
const cost = this.lastTurnDelta?.costUsd ?? 0
|
|
1539
|
-
if (cost > 0) metrics += ` · 💰 $${cost.toFixed(3)}`
|
|
1540
|
-
}
|
|
1541
|
-
const footer = `${stateMark} ⏱ ${elapsed}s${metrics}${sendNote}`
|
|
1542
|
-
await cardkit.streamText(cardId, cards.ELEMENTS.footer, footer)
|
|
1543
|
-
// Final chat-list preview: clean finish shows "⏱ Xs · NK tokens";
|
|
1544
|
-
// interrupted shows the suffix instead (no usage event landed).
|
|
1545
|
-
// cancelSummary kills any in-flight throttled write so a stale
|
|
1546
|
-
// mid-stream tail can't clobber this terminal summary.
|
|
1547
|
-
cardkit.cancelSummary(cardId)
|
|
1548
|
-
await cardkit.patchSettings(cardId, cards.streamingOffSettings({
|
|
1549
|
-
durationSec: elapsed,
|
|
1550
|
-
tokens: suffix ? undefined : this.lastTurnDelta?.tokens,
|
|
1551
|
-
suffix,
|
|
1552
|
-
}))
|
|
1553
|
-
await cardkit.dispose(cardId)
|
|
1554
|
-
|
|
1555
|
-
// Phone push on clean turn close so the user knows Claude is done
|
|
1556
|
-
// even with the chat backgrounded. Skip on interrupts (no real
|
|
1557
|
-
// completion), when we don't know who to ping, and when the turn
|
|
1558
|
-
// wasn't kicked off by the user typing a message — scheduled /
|
|
1559
|
-
// cron / loop wakeups finish on their own and shouldn't ping the
|
|
1560
|
-
// phone. Fire-and-forget; urgent_app failures are non-fatal and
|
|
1561
|
-
// already logged in feishu.ts.
|
|
1562
|
-
if (!suffix && turn.trigger === 'user_message' && turn.userOpenId && turn.messageId) {
|
|
1563
|
-
void feishu.urgentApp(turn.messageId, [turn.userOpenId])
|
|
1564
|
-
}
|
|
1565
|
-
|
|
1566
|
-
// Release the OneSecond reactions on every queued Feishu message
|
|
1567
|
-
// this turn was responsible for. Two buckets:
|
|
1568
|
-
// 1. `currentBatchReactionIds` — msgs the init handler explicitly
|
|
1569
|
-
// claimed (SDK dequeued them as a merged next-turn batch).
|
|
1570
|
-
// 2. `pendingReactionIds` — msgs whose fate is invisible to the
|
|
1571
|
-
// daemon: the SDK either dequeued them as part of the
|
|
1572
|
-
// JUST-CLOSED turn OR injected them mid-turn as
|
|
1573
|
-
// `<system-reminder>` and silently removed them from the
|
|
1574
|
-
// queue (common when the current turn had tool calls).
|
|
1575
|
-
// Without visibility into queue-operation events the daemon
|
|
1576
|
-
// can't tell which; the safe default is "the prior turn just
|
|
1577
|
-
// ended, so the msg is at least *acknowledged* now —
|
|
1578
|
-
// release the OneSecond and let it stop saying 'queued',
|
|
1579
|
-
// instead of leaving it stuck permanently."
|
|
1580
|
-
// For merged-batch follow-ups, this releases slightly early
|
|
1581
|
-
// (before the merged turn actually runs), which is an
|
|
1582
|
-
// acceptable trade vs. msgs stuck under OneSecond forever.
|
|
1583
|
-
const releaseEntries = [
|
|
1584
|
-
...this.currentBatchReactionIds.entries(),
|
|
1585
|
-
...this.pendingReactionIds.entries(),
|
|
1586
|
-
]
|
|
1587
|
-
if (releaseEntries.length > 0) {
|
|
1588
|
-
for (const [msgId, rid] of releaseEntries) {
|
|
1589
|
-
if (rid) void feishu.deleteReaction(msgId, rid)
|
|
1590
|
-
}
|
|
1591
|
-
this.currentBatchReactionIds = new Map()
|
|
1592
|
-
this.pendingReactionIds = new Map()
|
|
1593
|
-
}
|
|
1594
|
-
|
|
1595
|
-
// Fire uploads sequentially AFTER the card is sealed so each file
|
|
1596
|
-
// posts as its own Feishu message below the conversation card.
|
|
1597
|
-
// Path gate: workDir (Claude's project sandbox), the inbox where
|
|
1598
|
-
// user-uploaded attachments land, and the /tmp/lodestar- namespace
|
|
1599
|
-
// for ad-hoc artifacts. Anything outside is refused — see
|
|
1600
|
-
// feishu.isPathAllowed.
|
|
1601
|
-
const allowedRoots = [this.workDir, INBOX_DIR, '/tmp/lodestar-']
|
|
1602
|
-
for (const p of sendPaths) {
|
|
1603
|
-
await feishu.uploadAndSend(this.chatId, p, allowedRoots)
|
|
1604
|
-
}
|
|
1605
|
-
}
|
|
1606
|
-
}
|