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