@leviyuan/lodestar 0.2.8 → 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/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 +173 -642
- 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,8 +42,47 @@ 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
88
|
* USUALLY batch-merges every mid-turn user message into a single
|
|
@@ -186,35 +153,15 @@ export class Session {
|
|
|
186
153
|
* message. The flag tells the init handler "an eager open is already
|
|
187
154
|
* claiming the slot, stand down". */
|
|
188
155
|
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
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
|
|
218
165
|
// Last seen sessionId — preserved across `kill`/`stop` so a later
|
|
219
166
|
// `restart` can resume the same Claude conversation even after the
|
|
220
167
|
// child process is gone.
|
|
@@ -222,14 +169,6 @@ export class Session {
|
|
|
222
169
|
private startedAt: number = 0
|
|
223
170
|
private cumStats: CumStats = { tokens: 0, costUsd: 0, turns: 0 }
|
|
224
171
|
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
172
|
|
|
234
173
|
constructor(
|
|
235
174
|
public readonly sessionName: string,
|
|
@@ -246,19 +185,6 @@ export class Session {
|
|
|
246
185
|
}
|
|
247
186
|
}
|
|
248
187
|
|
|
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
188
|
/** Minimal cross-chat snapshot for the `hi` peer-list section.
|
|
263
189
|
* `startedAt` stays private so this is the documented read path. */
|
|
264
190
|
peerSnapshot(): { name: string; status: Status; uptimeMs?: number } {
|
|
@@ -297,6 +223,23 @@ export class Session {
|
|
|
297
223
|
})
|
|
298
224
|
this.wireProc(this.proc)
|
|
299
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
|
+
})
|
|
300
243
|
|
|
301
244
|
await feishu.sendText(this.chatId, `✅ Lodestar session "${this.sessionName}" 已就绪,发消息开始对话。`)
|
|
302
245
|
this.status = 'idle'
|
|
@@ -350,6 +293,7 @@ export class Session {
|
|
|
350
293
|
this.initCount = 0
|
|
351
294
|
this.openingTurn = false
|
|
352
295
|
this.pendingPermissions.clear()
|
|
296
|
+
this.consecutiveErrors = 0
|
|
353
297
|
this.status = 'stopped'
|
|
354
298
|
await proc.kill()
|
|
355
299
|
await feishu.sendText(this.chatId, `🔴 ${reason} (session: ${this.sessionName})`)
|
|
@@ -370,6 +314,7 @@ export class Session {
|
|
|
370
314
|
this.initCount = 0
|
|
371
315
|
this.openingTurn = false
|
|
372
316
|
this.pendingPermissions.clear()
|
|
317
|
+
this.consecutiveErrors = 0
|
|
373
318
|
if (resume && prevSessionId) {
|
|
374
319
|
this.proc = new ClaudeProcess({
|
|
375
320
|
workDir: this.workDir,
|
|
@@ -492,6 +437,10 @@ export class Session {
|
|
|
492
437
|
// reads better than `claude-opus-4-7` in the small status header.
|
|
493
438
|
const rawModel = this.proc?.lastModel ?? null
|
|
494
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()
|
|
495
444
|
const card = cards.consoleCard({
|
|
496
445
|
sessionName: this.sessionName,
|
|
497
446
|
status: this.status,
|
|
@@ -517,6 +466,7 @@ export class Session {
|
|
|
517
466
|
}
|
|
518
467
|
: undefined,
|
|
519
468
|
sessionId: this.proc?.sessionId ?? this.lastSessionId,
|
|
469
|
+
sysinfo,
|
|
520
470
|
})
|
|
521
471
|
const messageId = await feishu.sendCard(this.chatId, card)
|
|
522
472
|
if (!messageId) return
|
|
@@ -682,191 +632,28 @@ export class Session {
|
|
|
682
632
|
}
|
|
683
633
|
}
|
|
684
634
|
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
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)
|
|
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.
|
|
693
638
|
|
|
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
639
|
hasPendingAsk(): boolean {
|
|
728
|
-
return this
|
|
640
|
+
return sessionAsk.hasPendingAsk(this)
|
|
729
641
|
}
|
|
730
642
|
|
|
731
|
-
|
|
732
|
-
|
|
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
|
|
643
|
+
onAskMessageAnswer(text: string, user: string): Promise<void> {
|
|
644
|
+
return sessionAsk.onAskMessageAnswer(this, text, user)
|
|
738
645
|
}
|
|
739
646
|
|
|
740
|
-
|
|
741
|
-
|
|
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 })
|
|
647
|
+
onAskAnswer(toolUseId: string, questionIdx: number, optionIdx: number, user: string): Promise<void> {
|
|
648
|
+
return sessionAsk.onAskAnswer(this, toolUseId, questionIdx, optionIdx, user)
|
|
773
649
|
}
|
|
774
650
|
|
|
775
|
-
|
|
776
|
-
|
|
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 })
|
|
651
|
+
onAskCustomAnswer(toolUseId: string, questionIdx: number, customText: string, user: string): Promise<void> {
|
|
652
|
+
return sessionAsk.onAskCustomAnswer(this, toolUseId, questionIdx, customText, user)
|
|
787
653
|
}
|
|
788
654
|
|
|
789
|
-
|
|
790
|
-
|
|
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
|
-
}
|
|
655
|
+
onPermissionDecision(requestId: string, decision: 'allow' | 'allow_always' | 'deny', user: string): Promise<void> {
|
|
656
|
+
return sessionPermission.onPermissionDecision(this, requestId, decision, user)
|
|
870
657
|
}
|
|
871
658
|
|
|
872
659
|
// ── Wiring Claude → Feishu ─────────────────────────────────────────
|
|
@@ -939,13 +726,13 @@ export class Session {
|
|
|
939
726
|
this.appendThinking(text)
|
|
940
727
|
})
|
|
941
728
|
p.on('tool_use', ({ id, name, input }: { id: string; name: string; input: any }) => {
|
|
942
|
-
|
|
729
|
+
sessionTools.addTool(this, id, name, input)
|
|
943
730
|
})
|
|
944
731
|
p.on('tool_result', ({ tool_use_id, content, is_error }: any) => {
|
|
945
|
-
|
|
732
|
+
sessionTools.completeTool(this, tool_use_id, content, is_error)
|
|
946
733
|
})
|
|
947
734
|
p.on('can_use_tool', (req: CanUseToolRequest) => {
|
|
948
|
-
|
|
735
|
+
sessionPermission.renderPermission(this, req)
|
|
949
736
|
})
|
|
950
737
|
p.on('hook_callback', (req: HookCallbackRequest) => {
|
|
951
738
|
// No hooks registered → fail-safe ack.
|
|
@@ -953,21 +740,65 @@ export class Session {
|
|
|
953
740
|
})
|
|
954
741
|
p.on('result', () => {
|
|
955
742
|
this.accumulateResultStats()
|
|
956
|
-
//
|
|
957
|
-
//
|
|
958
|
-
//
|
|
959
|
-
//
|
|
960
|
-
//
|
|
961
|
-
//
|
|
962
|
-
//
|
|
963
|
-
//
|
|
964
|
-
//
|
|
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).
|
|
965
758
|
const hasMidTurn = this.pendingMidTurnMsgs.length > 0
|
|
966
|
-
const
|
|
967
|
-
|
|
968
|
-
|
|
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 })
|
|
969
787
|
this.status = 'idle'
|
|
970
|
-
|
|
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
|
+
}
|
|
971
802
|
})
|
|
972
803
|
p.on('exit', ({ code, signal, expected }: any) => {
|
|
973
804
|
log(`session "${this.sessionName}": claude exited code=${code} signal=${signal} expected=${expected}`)
|
|
@@ -979,6 +810,7 @@ export class Session {
|
|
|
979
810
|
this.releaseAllReactions()
|
|
980
811
|
this.initCount = 0
|
|
981
812
|
this.openingTurn = false
|
|
813
|
+
this.consecutiveErrors = 0
|
|
982
814
|
this.status = 'stopped'
|
|
983
815
|
if (!expected && code !== 0 && signal !== 'SIGTERM') {
|
|
984
816
|
void feishu.sendText(this.chatId, `⚠️ Claude 异常退出 (code=${code}, signal=${signal})。回复任意消息将重新启动。`)
|
|
@@ -1025,9 +857,11 @@ export class Session {
|
|
|
1025
857
|
* .contextWindow` captured by ClaudeProcess on each turn close, so
|
|
1026
858
|
* the daemon doesn't have to enumerate model ids itself (was the
|
|
1027
859
|
* source of a "560K/200K" display bug — model id didn't include
|
|
1028
|
-
* `[1m]` so the hardcoded fallback won).
|
|
1029
|
-
|
|
1030
|
-
|
|
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
|
|
1031
865
|
}
|
|
1032
866
|
|
|
1033
867
|
/** Drain `pendingMidTurnMsgs` to the SDK and open a fresh card for the
|
|
@@ -1176,308 +1010,7 @@ export class Session {
|
|
|
1176
1010
|
)
|
|
1177
1011
|
}
|
|
1178
1012
|
|
|
1179
|
-
private
|
|
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> {
|
|
1013
|
+
private async closeTurnCard(suffix?: string, opts: { forcePush?: boolean } = {}): Promise<void> {
|
|
1481
1014
|
// CRITICAL: capture-and-null in a single synchronous block at entry
|
|
1482
1015
|
// so a parallel `closeTurnCard` (e.g. result event firing while
|
|
1483
1016
|
// onUserMessage is awaiting an interrupt) can't double-process the
|
|
@@ -1531,7 +1064,7 @@ export class Session {
|
|
|
1531
1064
|
if (!suffix) {
|
|
1532
1065
|
const ctxTokens = this.currentContextTokens()
|
|
1533
1066
|
const ctxMax = this.contextWindowMax()
|
|
1534
|
-
if (ctxTokens > 0 && ctxMax > 0) {
|
|
1067
|
+
if (ctxTokens > 0 && ctxMax !== null && ctxMax > 0) {
|
|
1535
1068
|
const pct = Math.round((ctxTokens / ctxMax) * 100)
|
|
1536
1069
|
metrics += ` · 📊 ${pct}%`
|
|
1537
1070
|
}
|
|
@@ -1557,9 +1090,12 @@ export class Session {
|
|
|
1557
1090
|
// completion), when we don't know who to ping, and when the turn
|
|
1558
1091
|
// wasn't kicked off by the user typing a message — scheduled /
|
|
1559
1092
|
// cron / loop wakeups finish on their own and shouldn't ping the
|
|
1560
|
-
// phone.
|
|
1561
|
-
//
|
|
1562
|
-
|
|
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) {
|
|
1563
1099
|
void feishu.urgentApp(turn.messageId, [turn.userOpenId])
|
|
1564
1100
|
}
|
|
1565
1101
|
|
|
@@ -1594,13 +1130,8 @@ export class Session {
|
|
|
1594
1130
|
|
|
1595
1131
|
// Fire uploads sequentially AFTER the card is sealed so each file
|
|
1596
1132
|
// 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
1133
|
for (const p of sendPaths) {
|
|
1603
|
-
await feishu.uploadAndSend(this.chatId, p
|
|
1134
|
+
await feishu.uploadAndSend(this.chatId, p)
|
|
1604
1135
|
}
|
|
1605
1136
|
}
|
|
1606
1137
|
}
|