@leviyuan/lodestar 0.2.5 → 0.2.7
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/daemon.ts +77 -2
- package/package.json +1 -1
- package/src/cardkit.ts +109 -26
- package/src/cards.ts +26 -2
- package/src/paths.ts +8 -0
- package/src/session.ts +266 -83
package/daemon.ts
CHANGED
|
@@ -11,13 +11,13 @@
|
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
import * as lark from '@larksuiteoapi/node-sdk'
|
|
14
|
-
import { mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs'
|
|
14
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs'
|
|
15
15
|
import { dirname } from 'node:path'
|
|
16
16
|
import { Session } from './src/session'
|
|
17
17
|
import * as feishu from './src/feishu'
|
|
18
18
|
import { config } from './src/config'
|
|
19
19
|
import { log } from './src/log'
|
|
20
|
-
import { PID_FILE } from './src/paths'
|
|
20
|
+
import { DEBUG_CTX_FILE, DEBUG_SOCK_FILE, PID_FILE } from './src/paths'
|
|
21
21
|
|
|
22
22
|
// ── PID guard ───────────────────────────────────────────────────────────
|
|
23
23
|
try {
|
|
@@ -44,6 +44,7 @@ const cleanup = () => {
|
|
|
44
44
|
if (alive.length > 0) log(`alive marker: [${alive.join(', ')}]`)
|
|
45
45
|
} catch (e) { log(`alive marker write failed: ${e}`) }
|
|
46
46
|
try { unlinkSync(PID_FILE) } catch {}
|
|
47
|
+
try { unlinkSync(DEBUG_SOCK_FILE) } catch {}
|
|
47
48
|
}
|
|
48
49
|
process.on('exit', cleanup)
|
|
49
50
|
process.on('SIGTERM', () => { log('SIGTERM'); cleanup(); process.exit(0) })
|
|
@@ -126,6 +127,29 @@ async function handleMessage(data: any): Promise<void> {
|
|
|
126
127
|
}
|
|
127
128
|
|
|
128
129
|
const chatId = message.chat_id as string
|
|
130
|
+
|
|
131
|
+
// `[DEBUG]` prefix — seed the inject context with the real chat/sender
|
|
132
|
+
// captured from a live WS event, then strip the prefix and continue as
|
|
133
|
+
// normal. The injector script (scripts/test-inject.ts) reads this
|
|
134
|
+
// context to replay arbitrary messages without the user touching Feishu.
|
|
135
|
+
let contentObjForDebug: any = {}
|
|
136
|
+
try { contentObjForDebug = JSON.parse(message.content ?? '{}') } catch {}
|
|
137
|
+
const debugTextRaw = (message.message_type === 'text' ? contentObjForDebug.text ?? '' : '')
|
|
138
|
+
if (typeof debugTextRaw === 'string' && debugTextRaw.startsWith('[DEBUG]')) {
|
|
139
|
+
try {
|
|
140
|
+
writeFileSync(DEBUG_CTX_FILE, JSON.stringify({
|
|
141
|
+
chat_id: chatId,
|
|
142
|
+
sender_open_id: userOpenId,
|
|
143
|
+
seeded_at: new Date().toISOString(),
|
|
144
|
+
seeded_msg_id: msgId ?? '',
|
|
145
|
+
}, null, 2))
|
|
146
|
+
log(`debug: seeded inject context chat=${chatId.slice(0, 8)}… sender=${userOpenId.slice(0, 8)}…`)
|
|
147
|
+
} catch (e) { log(`debug: seed context failed: ${e}`) }
|
|
148
|
+
const stripped = debugTextRaw.slice('[DEBUG]'.length)
|
|
149
|
+
contentObjForDebug.text = stripped
|
|
150
|
+
message.content = JSON.stringify(contentObjForDebug)
|
|
151
|
+
}
|
|
152
|
+
|
|
129
153
|
let groupName = feishu.chatNameCache.get(chatId)
|
|
130
154
|
if (!groupName) {
|
|
131
155
|
await feishu.refreshChatList()
|
|
@@ -218,6 +242,55 @@ function fmt(m: any[]): string {
|
|
|
218
242
|
return m.map(x => typeof x === 'string' ? x : JSON.stringify(x)).join(' ')
|
|
219
243
|
}
|
|
220
244
|
|
|
245
|
+
// ── Debug message injection ─────────────────────────────────────────────
|
|
246
|
+
// Listens on a unix socket so scripts/test-inject.ts can replay messages
|
|
247
|
+
// through the same `handleMessage` path that real WS events take. Seeded
|
|
248
|
+
// by a one-time `[DEBUG]<anything>` from the real Feishu user; from then
|
|
249
|
+
// on the injector reuses that chat_id + sender_open_id.
|
|
250
|
+
function startDebugSocket(): void {
|
|
251
|
+
try { if (existsSync(DEBUG_SOCK_FILE)) unlinkSync(DEBUG_SOCK_FILE) } catch {}
|
|
252
|
+
try {
|
|
253
|
+
Bun.serve({
|
|
254
|
+
unix: DEBUG_SOCK_FILE,
|
|
255
|
+
fetch: async (req: Request) => {
|
|
256
|
+
if (req.method !== 'POST') return new Response('use POST', { status: 405 })
|
|
257
|
+
let body: any = {}
|
|
258
|
+
try { body = await req.json() } catch { return new Response('bad json', { status: 400 }) }
|
|
259
|
+
if (!existsSync(DEBUG_CTX_FILE)) {
|
|
260
|
+
return new Response('no debug context yet — send `[DEBUG]hi` from Feishu first', { status: 412 })
|
|
261
|
+
}
|
|
262
|
+
let ctx: any = {}
|
|
263
|
+
try { ctx = JSON.parse(readFileSync(DEBUG_CTX_FILE, 'utf8')) } catch (e) {
|
|
264
|
+
return new Response(`ctx read failed: ${e}`, { status: 500 })
|
|
265
|
+
}
|
|
266
|
+
const text: string = String(body.text ?? '')
|
|
267
|
+
if (!text) return new Response('text required', { status: 400 })
|
|
268
|
+
const fakeMsgId = `om_DEBUG_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`
|
|
269
|
+
const payload = {
|
|
270
|
+
sender: { sender_id: { open_id: ctx.sender_open_id } },
|
|
271
|
+
message: {
|
|
272
|
+
message_id: fakeMsgId,
|
|
273
|
+
chat_id: ctx.chat_id,
|
|
274
|
+
message_type: 'text',
|
|
275
|
+
content: JSON.stringify({ text }),
|
|
276
|
+
create_time: String(Date.now()),
|
|
277
|
+
},
|
|
278
|
+
}
|
|
279
|
+
log(`debug: inject text=${JSON.stringify(text).slice(0, 80)} fake_id=${fakeMsgId}`)
|
|
280
|
+
// Don't await — match real WS dispatcher behavior (fire-and-forget per event).
|
|
281
|
+
handleMessage(payload).catch(e => log(`debug: handleMessage rejected: ${e}`))
|
|
282
|
+
return new Response(JSON.stringify({ ok: true, fake_msg_id: fakeMsgId }), {
|
|
283
|
+
headers: { 'content-type': 'application/json' },
|
|
284
|
+
})
|
|
285
|
+
},
|
|
286
|
+
})
|
|
287
|
+
try { chmodSync(DEBUG_SOCK_FILE, 0o600) } catch {}
|
|
288
|
+
log(`debug: inject socket listening at ${DEBUG_SOCK_FILE}`)
|
|
289
|
+
} catch (e) {
|
|
290
|
+
log(`debug: socket bind failed: ${e}`)
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
221
294
|
async function boot(): Promise<void> {
|
|
222
295
|
log(`lodestar-daemon: pid ${process.pid} starting`)
|
|
223
296
|
feishu.loadSessionChatMap()
|
|
@@ -269,6 +342,8 @@ async function boot(): Promise<void> {
|
|
|
269
342
|
ws.start({ eventDispatcher: dispatcher })
|
|
270
343
|
log(`lodestar-daemon: WS started, watching ${feishu.chatNameCache.size} groups`)
|
|
271
344
|
|
|
345
|
+
startDebugSocket()
|
|
346
|
+
|
|
272
347
|
// Auto-revive sessions that were running when we last went down.
|
|
273
348
|
// Runs AFTER the WS is up so any 🔁 revive message lands in the
|
|
274
349
|
// right chat instead of disappearing into the void.
|
package/package.json
CHANGED
package/src/cardkit.ts
CHANGED
|
@@ -73,11 +73,68 @@ async function call(method: string, path: string, body?: object): Promise<any> {
|
|
|
73
73
|
})
|
|
74
74
|
const json = await res.json() as any
|
|
75
75
|
if (json?.code && json.code !== 0) {
|
|
76
|
-
|
|
76
|
+
const e = new Error(`cardkit ${method} ${path}: code=${json.code} msg=${json.msg}`) as Error & { code: number }
|
|
77
|
+
e.code = json.code
|
|
78
|
+
throw e
|
|
77
79
|
}
|
|
78
80
|
return json?.data
|
|
79
81
|
}
|
|
80
82
|
|
|
83
|
+
function isStreamingClosed(e: unknown): boolean {
|
|
84
|
+
return typeof e === 'object' && e !== null && (e as any).code === 300309
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Reopen streaming_mode on a card that Feishu auto-closed after its
|
|
88
|
+
* 10-minute streaming TTL (no keepalive, no idle reset — the timer
|
|
89
|
+
* starts when streaming is opened and fires regardless of activity).
|
|
90
|
+
* Called from inside the per-card queue's catch path, so it allocates
|
|
91
|
+
* its own sequence and runs inline without re-enqueueing. */
|
|
92
|
+
async function reopenStreaming(cardId: string): Promise<void> {
|
|
93
|
+
const seq = nextSeq(cardId)
|
|
94
|
+
await call('PATCH', `/cards/${cardId}/settings`, {
|
|
95
|
+
settings: JSON.stringify({ config: { streaming_mode: true } }),
|
|
96
|
+
sequence: seq,
|
|
97
|
+
})
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Run `op` inside the per-card queue. If it fails with code=300309
|
|
101
|
+
* (Feishu auto-closed streaming after the 10-minute TTL), reopen
|
|
102
|
+
* streaming inline and retry `op` exactly once. Anything else — non-
|
|
103
|
+
* 300309 failure, reopen failure, retry failure — is logged and
|
|
104
|
+
* swallowed, matching the fire-and-forget contract every cardkit op
|
|
105
|
+
* already has at the call sites. */
|
|
106
|
+
async function withReopenOnStreamingClosed(
|
|
107
|
+
cardId: string,
|
|
108
|
+
label: string,
|
|
109
|
+
op: () => Promise<void>,
|
|
110
|
+
onFailure?: () => void,
|
|
111
|
+
): Promise<void> {
|
|
112
|
+
try {
|
|
113
|
+
await op()
|
|
114
|
+
return
|
|
115
|
+
} catch (e) {
|
|
116
|
+
if (!isStreamingClosed(e)) {
|
|
117
|
+
log(`cardkit ${label} ${cardId}: ${e}`)
|
|
118
|
+
if (onFailure) onFailure()
|
|
119
|
+
return
|
|
120
|
+
}
|
|
121
|
+
log(`cardkit ${label} ${cardId}: streaming closed (300309) — reopening`)
|
|
122
|
+
}
|
|
123
|
+
try {
|
|
124
|
+
await reopenStreaming(cardId)
|
|
125
|
+
} catch (re) {
|
|
126
|
+
log(`cardkit STREAMING_REOPEN_FAILED ${cardId}: ${re}`)
|
|
127
|
+
if (onFailure) onFailure()
|
|
128
|
+
return
|
|
129
|
+
}
|
|
130
|
+
try {
|
|
131
|
+
await op()
|
|
132
|
+
} catch (e2) {
|
|
133
|
+
log(`cardkit ${label} ${cardId} retry-after-reopen: ${e2}`)
|
|
134
|
+
if (onFailure) onFailure()
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
81
138
|
/** Convert a sent interactive message into a card entity. */
|
|
82
139
|
export async function convertMessageToCard(messageId: string): Promise<string> {
|
|
83
140
|
const data = await call('POST', '/cards/id_convert', { message_id: messageId })
|
|
@@ -101,17 +158,17 @@ export async function createCardEntity(card: object): Promise<string> {
|
|
|
101
158
|
export function streamText(cardId: string, elementId: string, content: string): Promise<void> {
|
|
102
159
|
if (!content || !content.trim()) return Promise.resolve()
|
|
103
160
|
const s = state(cardId)
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
161
|
+
s.queue = s.queue.then(() => withReopenOnStreamingClosed(
|
|
162
|
+
cardId,
|
|
163
|
+
`streamText ${elementId}`,
|
|
164
|
+
async () => {
|
|
165
|
+
const seq = nextSeq(cardId)
|
|
107
166
|
await call('PUT', `/cards/${cardId}/elements/${elementId}/content`, {
|
|
108
167
|
content, sequence: seq,
|
|
109
168
|
})
|
|
110
169
|
s.lastSent.set(elementId, content)
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
}
|
|
114
|
-
})
|
|
170
|
+
},
|
|
171
|
+
))
|
|
115
172
|
return s.queue
|
|
116
173
|
}
|
|
117
174
|
|
|
@@ -148,53 +205,69 @@ export async function flush(cardId: string): Promise<void> {
|
|
|
148
205
|
}
|
|
149
206
|
}
|
|
150
207
|
|
|
151
|
-
/** Add a new element to the card body or relative to a sibling.
|
|
208
|
+
/** Add a new element to the card body or relative to a sibling.
|
|
209
|
+
*
|
|
210
|
+
* `onFailure` fires asynchronously (after promise queue settles) if the
|
|
211
|
+
* element was NOT created — either the first attempt failed with a non-
|
|
212
|
+
* 300309 error, or the retry-after-reopen also failed. Use it to invalidate
|
|
213
|
+
* any daemon-side reference to the element you tried to add (e.g. a segment
|
|
214
|
+
* id), so subsequent writes don't keep PUTting content to a phantom element
|
|
215
|
+
* that Feishu will silently reject. Default (no callback) preserves the
|
|
216
|
+
* legacy fire-and-forget swallow behavior. */
|
|
152
217
|
export function addElement(
|
|
153
218
|
cardId: string,
|
|
154
219
|
element: object,
|
|
155
220
|
opts: { type?: 'append' | 'insert_before' | 'insert_after'; targetElementId?: string } = {},
|
|
221
|
+
onFailure?: () => void,
|
|
156
222
|
): Promise<void> {
|
|
157
223
|
const s = state(cardId)
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
224
|
+
s.queue = s.queue.then(() => withReopenOnStreamingClosed(
|
|
225
|
+
cardId,
|
|
226
|
+
`addElement`,
|
|
227
|
+
async () => {
|
|
228
|
+
const seq = nextSeq(cardId)
|
|
161
229
|
await call('POST', `/cards/${cardId}/elements`, {
|
|
162
230
|
type: opts.type ?? 'append',
|
|
163
231
|
...(opts.targetElementId ? { target_element_id: opts.targetElementId } : {}),
|
|
164
232
|
elements: JSON.stringify([element]),
|
|
165
233
|
sequence: seq,
|
|
166
234
|
})
|
|
167
|
-
}
|
|
168
|
-
|
|
235
|
+
},
|
|
236
|
+
onFailure,
|
|
237
|
+
))
|
|
169
238
|
return s.queue
|
|
170
239
|
}
|
|
171
240
|
|
|
172
241
|
/** Replace an entire element (used to swap a tool placeholder with its result). */
|
|
173
242
|
export function replaceElement(cardId: string, elementId: string, element: object): Promise<void> {
|
|
174
243
|
const s = state(cardId)
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
244
|
+
s.queue = s.queue.then(() => withReopenOnStreamingClosed(
|
|
245
|
+
cardId,
|
|
246
|
+
`replaceElement ${elementId}`,
|
|
247
|
+
async () => {
|
|
248
|
+
const seq = nextSeq(cardId)
|
|
178
249
|
await call('PUT', `/cards/${cardId}/elements/${elementId}`, {
|
|
179
250
|
element: JSON.stringify(element),
|
|
180
251
|
sequence: seq,
|
|
181
252
|
})
|
|
182
|
-
}
|
|
183
|
-
|
|
253
|
+
},
|
|
254
|
+
))
|
|
184
255
|
return s.queue
|
|
185
256
|
}
|
|
186
257
|
|
|
187
258
|
/** Delete an element by id. */
|
|
188
259
|
export function deleteElement(cardId: string, elementId: string): Promise<void> {
|
|
189
260
|
const s = state(cardId)
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
261
|
+
s.queue = s.queue.then(() => withReopenOnStreamingClosed(
|
|
262
|
+
cardId,
|
|
263
|
+
`deleteElement ${elementId}`,
|
|
264
|
+
async () => {
|
|
265
|
+
const seq = nextSeq(cardId)
|
|
193
266
|
await call('DELETE', `/cards/${cardId}/elements/${elementId}`, {
|
|
194
267
|
sequence: seq,
|
|
195
268
|
})
|
|
196
|
-
}
|
|
197
|
-
|
|
269
|
+
},
|
|
270
|
+
))
|
|
198
271
|
return s.queue
|
|
199
272
|
}
|
|
200
273
|
|
|
@@ -235,12 +308,22 @@ export function cancelSummary(cardId: string): void {
|
|
|
235
308
|
summaryStates.delete(cardId)
|
|
236
309
|
}
|
|
237
310
|
|
|
238
|
-
/** Patch settings — used to flip streaming_mode off when a turn finishes.
|
|
311
|
+
/** Patch settings — used to flip streaming_mode off when a turn finishes.
|
|
312
|
+
*
|
|
313
|
+
* `nextSeq` is called inside the queued task (not at enqueue time) to
|
|
314
|
+
* match streamText/addElement/replaceElement/deleteElement above. Mixing
|
|
315
|
+
* call-time and execution-time seq allocation interleaves badly: a
|
|
316
|
+
* patchSettings enqueued right after a replaceElement would grab the
|
|
317
|
+
* smaller seq number, but the replaceElement's then-block would grab
|
|
318
|
+
* the larger one when it ran first, so the patchSettings PATCH lands
|
|
319
|
+
* with a stale seq and Feishu rejects 300317 "sequence number compare
|
|
320
|
+
* failed". Keeping all writes on execution-time allocation makes the
|
|
321
|
+
* seq order match the queue order. */
|
|
239
322
|
export function patchSettings(cardId: string, settings: object): Promise<void> {
|
|
240
323
|
const s = state(cardId)
|
|
241
|
-
const seq = nextSeq(cardId)
|
|
242
324
|
s.queue = s.queue.then(async () => {
|
|
243
325
|
try {
|
|
326
|
+
const seq = nextSeq(cardId)
|
|
244
327
|
await call('PATCH', `/cards/${cardId}/settings`, {
|
|
245
328
|
settings: JSON.stringify(settings),
|
|
246
329
|
sequence: seq,
|
package/src/cards.ts
CHANGED
|
@@ -74,7 +74,7 @@ export function summarizeToolInput(name: string, input: any): string {
|
|
|
74
74
|
return truncate(summarizeTaskWorkflow(name, input), 80)
|
|
75
75
|
}
|
|
76
76
|
switch (name) {
|
|
77
|
-
case 'Bash': return truncate(String(input.command ?? ''), 80)
|
|
77
|
+
case 'Bash': return truncate(String(input.description ?? input.command ?? ''), 80)
|
|
78
78
|
case 'Read':
|
|
79
79
|
case 'Write':
|
|
80
80
|
case 'Edit':
|
|
@@ -181,7 +181,7 @@ interface MainCardOpts {
|
|
|
181
181
|
/** Initial card sent at the start of each turn. Streaming on. */
|
|
182
182
|
export function mainConversationCard(opts: MainCardOpts): object {
|
|
183
183
|
const banner = opts.kind === 'scheduled'
|
|
184
|
-
? [{ tag: 'markdown', content: '⏰
|
|
184
|
+
? [{ tag: 'markdown', content: '⏰ 触发' }]
|
|
185
185
|
: []
|
|
186
186
|
return {
|
|
187
187
|
schema: '2.0',
|
|
@@ -276,6 +276,30 @@ export function toolCallElement(
|
|
|
276
276
|
}
|
|
277
277
|
}
|
|
278
278
|
|
|
279
|
+
/** Merged panel for a run of consecutive `Read` tool calls in one turn.
|
|
280
|
+
* Header shows the dynamic count (`Read · 3 次`), body lists one row per
|
|
281
|
+
* Read with its own status + file path. Replaces the individual panels
|
|
282
|
+
* once a second Read joins the batch — single Reads still render as the
|
|
283
|
+
* full `toolCallElement` (with file-contents dump on completion). */
|
|
284
|
+
export function readBatchElement(
|
|
285
|
+
i: number,
|
|
286
|
+
items: Array<{ input: any; output: string | null; isError: boolean }>,
|
|
287
|
+
): object {
|
|
288
|
+
const n = items.length
|
|
289
|
+
const anyError = items.some(it => it.isError)
|
|
290
|
+
const allDone = items.every(it => it.output !== null)
|
|
291
|
+
const status = anyError ? '❌' : allDone ? '✅' : '⏳'
|
|
292
|
+
const headerText = `${status} 🔧 Read · ${n} 次`
|
|
293
|
+
const lines = items.map(it => `\`${String(it.input.file_path ?? '(无 path)')}\``)
|
|
294
|
+
return {
|
|
295
|
+
tag: 'collapsible_panel',
|
|
296
|
+
element_id: ELEMENTS.tool(i),
|
|
297
|
+
header: { title: { tag: 'plain_text', content: headerText } },
|
|
298
|
+
expanded: false,
|
|
299
|
+
elements: [{ tag: 'markdown', content: lines.join('\n') }],
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
279
303
|
/** Same tool panel as `toolCallElement`, but with the 🔐 status and
|
|
280
304
|
* three inline action buttons (allow / allow_always / deny). Expanded
|
|
281
305
|
* by default so the user can read the request without clicking through.
|
package/src/paths.ts
CHANGED
|
@@ -47,3 +47,11 @@ export const SESSION_RESUME_MAP_FILE = join(DATA_DIR, 'session-resume-map.json')
|
|
|
47
47
|
* Lodestar's "you have to send a message to re-spawn it" default. */
|
|
48
48
|
export const ALIVE_MARKER_FILE = join(DATA_DIR, 'alive-on-shutdown.json')
|
|
49
49
|
export const INBOX_DIR = join(DATA_DIR, 'inbox')
|
|
50
|
+
/** Unix-socket endpoint the daemon listens on for debug message
|
|
51
|
+
* injection (see scripts/test-inject.ts). A real Feishu user sends
|
|
52
|
+
* a `[DEBUG]…` text once to seed `DEBUG_CTX_FILE` with chat_id +
|
|
53
|
+
* sender_open_id; the injector then POSTs `{text}` here and the
|
|
54
|
+
* daemon replays it through `handleMessage` as if WS had delivered
|
|
55
|
+
* it. File mode 0600 — daemon-private, never network-exposed. */
|
|
56
|
+
export const DEBUG_SOCK_FILE = join(DATA_DIR, 'debug.sock')
|
|
57
|
+
export const DEBUG_CTX_FILE = join(DATA_DIR, 'debug-context.json')
|
package/src/session.ts
CHANGED
|
@@ -47,7 +47,23 @@ interface TurnState {
|
|
|
47
47
|
resolvedNote?: string
|
|
48
48
|
output?: string
|
|
49
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
|
|
50
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
|
|
51
67
|
assistantSegmentCount: number
|
|
52
68
|
currentAssistantSegmentId: string | null
|
|
53
69
|
currentAssistantText: string
|
|
@@ -75,11 +91,14 @@ interface LastTurnDelta {
|
|
|
75
91
|
inputTokens: number // input + cache_* (excludes output) — context-window estimate
|
|
76
92
|
}
|
|
77
93
|
|
|
78
|
-
/** Cumulative session counters. Reset on full restart (`clear`),
|
|
79
|
-
* across resume
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
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. */
|
|
83
102
|
interface CumStats {
|
|
84
103
|
tokens: number
|
|
85
104
|
costUsd: number
|
|
@@ -112,6 +131,15 @@ export class Session {
|
|
|
112
131
|
* turns from cron-fired scheduled wakeups: count > 0 ⇒ user;
|
|
113
132
|
* count === 0 ⇒ scheduled (and `initCount > 1`). */
|
|
114
133
|
private pendingUserMessageCount = 0
|
|
134
|
+
/** Mid-turn user messages buffered DAEMON-SIDE (not yet sendUserText'd
|
|
135
|
+
* to the SDK). Drained in the `result` handler by writing each to SDK
|
|
136
|
+
* stdin, which doubles as the `priority="now"` wake signal the SDK
|
|
137
|
+
* polling loop needs to start the next batch turn (the SDK won't
|
|
138
|
+
* auto-dequeue queued type-ahead msgs after `result` — confirmed via
|
|
139
|
+
* claude-code issue #39632). Buffering also keeps mid-turn msgs out
|
|
140
|
+
* of any AskUserQuestion `QUEUE remove` storm, since they were never
|
|
141
|
+
* in the SDK queue to begin with. */
|
|
142
|
+
private pendingMidTurnMsgs: Array<{ wireText: string; files: string[]; userOpenId: string; msgId: string }> = []
|
|
115
143
|
/** Most recent userOpenId seen via `onUserMessage`. Used only when a
|
|
116
144
|
* merged batch fires its init event and the daemon needs *some* open_id
|
|
117
145
|
* to scope the eventual `urgent_app` push — there's no obviously right
|
|
@@ -269,6 +297,27 @@ export class Session {
|
|
|
269
297
|
return true
|
|
270
298
|
}
|
|
271
299
|
|
|
300
|
+
/** Drop every ⏳ OneSecond reaction this session is currently holding
|
|
301
|
+
* on user chat messages, then empty the two tracking maps. Used by
|
|
302
|
+
* every tear-down path (proc exit, kill, restart) so reactions don't
|
|
303
|
+
* outlive the conversation that placed them — without this, a Claude
|
|
304
|
+
* crash / daemon SIGTERM leaves orphan ⏳ stuck on user messages until
|
|
305
|
+
* Feishu's UI eventually GCs them (which it doesn't, in practice).
|
|
306
|
+
* closeTurnCard has its own release pass (with the slightly-early
|
|
307
|
+
* merged-batch trade-off documented there); this is the catastrophic-
|
|
308
|
+
* exit pass. Direct `deleteReaction` calls are fire-and-forget and
|
|
309
|
+
* swallow their own failures (see feishu.deleteReaction). */
|
|
310
|
+
private releaseAllReactions(): void {
|
|
311
|
+
for (const [msgId, rid] of [
|
|
312
|
+
...this.pendingReactionIds.entries(),
|
|
313
|
+
...this.currentBatchReactionIds.entries(),
|
|
314
|
+
]) {
|
|
315
|
+
if (rid) void feishu.deleteReaction(msgId, rid)
|
|
316
|
+
}
|
|
317
|
+
this.pendingReactionIds = new Map()
|
|
318
|
+
this.currentBatchReactionIds = new Map()
|
|
319
|
+
}
|
|
320
|
+
|
|
272
321
|
async stop(reason = '已终止'): Promise<void> {
|
|
273
322
|
if (!this.proc) {
|
|
274
323
|
this.status = 'stopped'
|
|
@@ -288,9 +337,9 @@ export class Session {
|
|
|
288
337
|
this.proc = null
|
|
289
338
|
this.currentTurn = null
|
|
290
339
|
this.pendingUserMessageCount = 0
|
|
340
|
+
this.pendingMidTurnMsgs = []
|
|
291
341
|
this.lastUserOpenId = ''
|
|
292
|
-
this.
|
|
293
|
-
this.currentBatchReactionIds = new Map()
|
|
342
|
+
this.releaseAllReactions()
|
|
294
343
|
this.initCount = 0
|
|
295
344
|
this.openingTurn = false
|
|
296
345
|
this.pendingPermissions.clear()
|
|
@@ -308,9 +357,9 @@ export class Session {
|
|
|
308
357
|
}
|
|
309
358
|
this.currentTurn = null
|
|
310
359
|
this.pendingUserMessageCount = 0
|
|
360
|
+
this.pendingMidTurnMsgs = []
|
|
311
361
|
this.lastUserOpenId = ''
|
|
312
|
-
this.
|
|
313
|
-
this.currentBatchReactionIds = new Map()
|
|
362
|
+
this.releaseAllReactions()
|
|
314
363
|
this.initCount = 0
|
|
315
364
|
this.openingTurn = false
|
|
316
365
|
this.pendingPermissions.clear()
|
|
@@ -372,11 +421,11 @@ export class Session {
|
|
|
372
421
|
// but the daemon can't reach into it directly; in practice the
|
|
373
422
|
// sendInterrupt() control_request causes the SDK to discard
|
|
374
423
|
// queued input alongside the in-flight call.
|
|
375
|
-
if (!this.currentTurn && this.pendingUserMessageCount === 0) {
|
|
424
|
+
if (!this.currentTurn && this.pendingUserMessageCount === 0 && this.pendingMidTurnMsgs.length === 0) {
|
|
376
425
|
await feishu.sendText(this.chatId, '⚪ 当前没有正在执行的 turn')
|
|
377
426
|
return true
|
|
378
427
|
}
|
|
379
|
-
log(`session "${this.sessionName}": stop command — interrupt + drop count=${this.pendingUserMessageCount}`)
|
|
428
|
+
log(`session "${this.sessionName}": stop command — interrupt + drop count=${this.pendingUserMessageCount} midBuffer=${this.pendingMidTurnMsgs.length}`)
|
|
380
429
|
// Cancelled queued msgs: remove the OneSecond (no longer waiting)
|
|
381
430
|
// and stamp a CrossMark (explicit cancelled state, distinct from
|
|
382
431
|
// a natural release where reactions just disappear). Cancelled
|
|
@@ -388,11 +437,20 @@ export class Session {
|
|
|
388
437
|
if (rid) void feishu.deleteReaction(msgId, rid)
|
|
389
438
|
void feishu.addReaction(msgId, 'CrossMark')
|
|
390
439
|
}
|
|
440
|
+
// Mid-turn buffer never reached SDK — cancel those too.
|
|
441
|
+
for (const msg of this.pendingMidTurnMsgs) {
|
|
442
|
+
if (msg.msgId) void feishu.addReaction(msg.msgId, 'CrossMark')
|
|
443
|
+
}
|
|
391
444
|
this.pendingUserMessageCount = 0
|
|
445
|
+
this.pendingMidTurnMsgs = []
|
|
392
446
|
this.lastUserOpenId = ''
|
|
393
447
|
this.pendingReactionIds = new Map()
|
|
394
448
|
this.currentBatchReactionIds = new Map()
|
|
395
449
|
this.interrupt()
|
|
450
|
+
// SDK 收到 interrupt 后不发 `result`,没人会触发 closeTurnCard。
|
|
451
|
+
// 这里主动封口,把 footer 改成 🛑 打断、折叠 thinking、把
|
|
452
|
+
// streaming_mode 翻回 false,否则卡片会僵在 `⏳ working…`。
|
|
453
|
+
await this.closeTurnCard('🛑 打断')
|
|
396
454
|
return true
|
|
397
455
|
case 'kill':
|
|
398
456
|
await this.stop()
|
|
@@ -532,8 +590,8 @@ export class Session {
|
|
|
532
590
|
// reaction) and the model would see e.g. "123" + "321" + "1"
|
|
533
591
|
// glued into a single string "1233211" (2026-05-16 accumulator
|
|
534
592
|
// bug).
|
|
535
|
-
const wasBusy = this.currentTurn !== null || this.openingTurn
|
|
536
|
-
|
|
593
|
+
const wasBusy = this.currentTurn !== null || this.openingTurn
|
|
594
|
+
|| this.pendingUserMessageCount > 0 || this.pendingMidTurnMsgs.length > 0
|
|
537
595
|
this.lastUserOpenId = userOpenId
|
|
538
596
|
// When the SDK will merge this msg with siblings into a multi-
|
|
539
597
|
// content user turn, wrap it in `<u>...</u>` so the model sees a
|
|
@@ -547,59 +605,73 @@ export class Session {
|
|
|
547
605
|
// skips the wrap — no sibling, no merge, no need. Contract
|
|
548
606
|
// declared in CHANNEL_INSTRUCTIONS.
|
|
549
607
|
const wireText = wasBusy ? `<u>${text}</u>` : text
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
// string is a no-op (deleteReaction guards against it), which is
|
|
556
|
-
// the right behavior if the add failed.
|
|
557
|
-
this.pendingReactionIds.set(msgId, '')
|
|
608
|
+
|
|
609
|
+
// Reaction helper: track the OneSecond reaction so deleteReaction can
|
|
610
|
+
// clear it later. Use empty-string sentinel until addReaction returns.
|
|
611
|
+
const trackReaction = (id: string) => {
|
|
612
|
+
this.pendingReactionIds.set(id, '')
|
|
558
613
|
void (async () => {
|
|
559
|
-
const rid = await feishu.addReaction(
|
|
614
|
+
const rid = await feishu.addReaction(id, 'OneSecond')
|
|
560
615
|
if (!rid) return
|
|
561
|
-
if (this.pendingReactionIds.has(
|
|
562
|
-
this.pendingReactionIds.set(
|
|
563
|
-
} else if (this.currentBatchReactionIds.has(
|
|
564
|
-
|
|
565
|
-
// addReaction was in flight — record the rid there so the
|
|
566
|
-
// batch's close-time deleteReaction sees it.
|
|
567
|
-
this.currentBatchReactionIds.set(msgId, rid)
|
|
616
|
+
if (this.pendingReactionIds.has(id)) {
|
|
617
|
+
this.pendingReactionIds.set(id, rid)
|
|
618
|
+
} else if (this.currentBatchReactionIds.has(id)) {
|
|
619
|
+
this.currentBatchReactionIds.set(id, rid)
|
|
568
620
|
} else {
|
|
569
|
-
// Orphan: both maps cleared
|
|
570
|
-
// them) before our add returned. The reaction is now stuck
|
|
571
|
-
// on the Feishu message with no one tracking it — delete
|
|
621
|
+
// Orphan: both maps cleared before our add returned. Delete
|
|
572
622
|
// directly so the user doesn't see a stale ⏳ forever.
|
|
573
|
-
|
|
574
|
-
// turn, 2 addReaction callbacks landed after close fired the
|
|
575
|
-
// release loop, those rids never made it back into either
|
|
576
|
-
// map → 2 stuck ⏳ in chat.)
|
|
577
|
-
void feishu.deleteReaction(msgId, rid)
|
|
623
|
+
void feishu.deleteReaction(id, rid)
|
|
578
624
|
}
|
|
579
625
|
})()
|
|
580
626
|
}
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
627
|
+
|
|
628
|
+
if (this.currentTurn !== null) {
|
|
629
|
+
// Mid-turn — BUFFER instead of immediate sendUserText. The SDK polling
|
|
630
|
+
// loop will not auto-dequeue queued type-ahead msgs after `result`
|
|
631
|
+
// (only `priority="now"` writes wake it — claude-code issue #39632),
|
|
632
|
+
// so writing here would leave the msg stuck until the next user msg
|
|
633
|
+
// arrives. Drain happens in the `result` handler, which both wakes
|
|
634
|
+
// the SDK and opens a fresh card for the new batch turn.
|
|
635
|
+
this.pendingMidTurnMsgs.push({ wireText, files, userOpenId, msgId })
|
|
636
|
+
if (msgId) trackReaction(msgId)
|
|
637
|
+
return
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
// No in-flight turn: send straight to SDK. This path handles
|
|
641
|
+
// - first message after spawn (init not yet fired)
|
|
642
|
+
// - bootstrap race (sibling msgs landing before init#1)
|
|
643
|
+
// - solo message after a prior turn has fully closed
|
|
644
|
+
// Eager-open path: open the card BEFORE feeding SDK, so a card-open
|
|
645
|
+
// failure doesn't strand the daemon with SDK processing a turn we
|
|
646
|
+
// have nowhere to render. `!openingTurn` means no sibling is mid-
|
|
647
|
+
// open; `initCount >= 1` means SDK boot init has fired (otherwise
|
|
648
|
+
// the init handler owns turn opening and we just feed the queue
|
|
649
|
+
// below). On failure openTurnCard surfaces a red banner via
|
|
650
|
+
// sendTextRaw; SDK was idle so no interrupt needed.
|
|
651
|
+
if (!this.openingTurn && this.initCount >= 1) {
|
|
595
652
|
this.openingTurn = true
|
|
596
|
-
this.pendingUserMessageCount--
|
|
597
653
|
try {
|
|
598
654
|
await this.openTurnCard(userOpenId, 'user_message')
|
|
655
|
+
if (!this.currentTurn) return
|
|
656
|
+
this.proc!.sendUserText(wireText, files)
|
|
657
|
+
this.pendingUserMessageCount++
|
|
599
658
|
this.status = 'working'
|
|
600
659
|
} finally {
|
|
601
660
|
this.openingTurn = false
|
|
602
661
|
}
|
|
662
|
+
return
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
// Non-eager path: either init hasn't fired yet (cold start) or a
|
|
666
|
+
// sibling onUserMessage is already opening. Feed SDK directly; the
|
|
667
|
+
// init handler / sibling card-opener will batch this message in.
|
|
668
|
+
this.proc!.sendUserText(wireText, files)
|
|
669
|
+
this.pendingUserMessageCount++
|
|
670
|
+
if (wasBusy && msgId) {
|
|
671
|
+
// Bootstrap race / sibling-opening race: until a card is open,
|
|
672
|
+
// the OneSecond ⏳ is the only ack the user gets. The init handler
|
|
673
|
+
// inherits these via currentBatchReactionIds when it opens.
|
|
674
|
+
trackReaction(msgId)
|
|
603
675
|
}
|
|
604
676
|
}
|
|
605
677
|
|
|
@@ -655,7 +727,7 @@ export class Session {
|
|
|
655
727
|
* acknowledgement that their type-ahead message landed (the card
|
|
656
728
|
* doesn't open until the current turn finishes). */
|
|
657
729
|
isBusy(): boolean {
|
|
658
|
-
return this.currentTurn !== null || this.pendingUserMessageCount > 0
|
|
730
|
+
return this.currentTurn !== null || this.pendingUserMessageCount > 0 || this.pendingMidTurnMsgs.length > 0
|
|
659
731
|
}
|
|
660
732
|
|
|
661
733
|
/** Funnel an arbitrary chat message into the *current* question
|
|
@@ -801,6 +873,7 @@ export class Session {
|
|
|
801
873
|
feishu.bindSessionResume(this.sessionName, p.sessionId)
|
|
802
874
|
}
|
|
803
875
|
this.initCount++
|
|
876
|
+
log(`session "${this.sessionName}": SDK init#${this.initCount} pendingCount=${this.pendingUserMessageCount} midBuffer=${this.pendingMidTurnMsgs.length} currentTurn=${this.currentTurn ? 'yes' : 'no'} openingTurn=${this.openingTurn}`)
|
|
804
877
|
|
|
805
878
|
// Boot init (initCount === 1) is claimed by `onUserMessage`'s
|
|
806
879
|
// eager-open path — if a user message landed before the init
|
|
@@ -834,7 +907,19 @@ export class Session {
|
|
|
834
907
|
void (async () => {
|
|
835
908
|
try {
|
|
836
909
|
await this.openTurnCard(userOpenId, isUserBatch ? 'user_message' : 'scheduled')
|
|
837
|
-
this.
|
|
910
|
+
if (!this.currentTurn) {
|
|
911
|
+
// SDK already started this turn (its `init` is what got us
|
|
912
|
+
// here) but we have no card to render into. Interrupt so
|
|
913
|
+
// assistant/tool events aren't silently dropped while the
|
|
914
|
+
// model burns tokens. Release the reactions this batch
|
|
915
|
+
// inherited (init handler moved them above) — otherwise
|
|
916
|
+
// they stay ⏳ forever on the user's chat messages.
|
|
917
|
+
log(`session "${this.sessionName}": init-path openTurnCard failed — sendInterrupt + release reactions`)
|
|
918
|
+
this.proc?.sendInterrupt()
|
|
919
|
+
this.releaseAllReactions()
|
|
920
|
+
} else {
|
|
921
|
+
this.status = 'working'
|
|
922
|
+
}
|
|
838
923
|
} finally {
|
|
839
924
|
this.openingTurn = false
|
|
840
925
|
}
|
|
@@ -861,24 +946,30 @@ export class Session {
|
|
|
861
946
|
})
|
|
862
947
|
p.on('result', () => {
|
|
863
948
|
this.accumulateResultStats()
|
|
864
|
-
//
|
|
865
|
-
//
|
|
866
|
-
//
|
|
867
|
-
//
|
|
868
|
-
//
|
|
869
|
-
//
|
|
870
|
-
|
|
949
|
+
// Daemon-driven rotation: mid-turn msgs were buffered (not yet
|
|
950
|
+
// sent to SDK) — close the in-flight card with `📨 转交新卡` and
|
|
951
|
+
// drain the buffer in one shot. The drain writes each buffered
|
|
952
|
+
// msg to SDK stdin, which is the `priority="now"` wake the SDK
|
|
953
|
+
// polling loop needs (claude-code issue #39632) AND constitutes
|
|
954
|
+
// the input for the new batch turn. We open the new card here
|
|
955
|
+
// ourselves rather than waiting on init — the SDK init for this
|
|
956
|
+
// batch will fire shortly but `currentTurn` will already be set,
|
|
957
|
+
// so the init handler will return without double-opening.
|
|
958
|
+
const hasMidTurn = this.pendingMidTurnMsgs.length > 0
|
|
959
|
+
const suffix = hasMidTurn ? '📨 转交新卡' : undefined
|
|
960
|
+
log(`session "${this.sessionName}": SDK result midBuffer=${this.pendingMidTurnMsgs.length} suffix=${suffix ?? '<✅>'}`)
|
|
871
961
|
void this.closeTurnCard(suffix)
|
|
872
962
|
this.status = 'idle'
|
|
963
|
+
if (hasMidTurn) void this.drainMidTurnAndOpen()
|
|
873
964
|
})
|
|
874
965
|
p.on('exit', ({ code, signal, expected }: any) => {
|
|
875
966
|
log(`session "${this.sessionName}": claude exited code=${code} signal=${signal} expected=${expected}`)
|
|
876
967
|
this.proc = null
|
|
877
968
|
this.currentTurn = null
|
|
878
969
|
this.pendingUserMessageCount = 0
|
|
970
|
+
this.pendingMidTurnMsgs = []
|
|
879
971
|
this.lastUserOpenId = ''
|
|
880
|
-
this.
|
|
881
|
-
this.currentBatchReactionIds = new Map()
|
|
972
|
+
this.releaseAllReactions()
|
|
882
973
|
this.initCount = 0
|
|
883
974
|
this.openingTurn = false
|
|
884
975
|
this.status = 'stopped'
|
|
@@ -932,8 +1023,39 @@ export class Session {
|
|
|
932
1023
|
return this.proc?.lastContextWindow ?? 200_000
|
|
933
1024
|
}
|
|
934
1025
|
|
|
1026
|
+
/** Drain `pendingMidTurnMsgs` to the SDK and open a fresh card for the
|
|
1027
|
+
* resulting batch turn. Called from the `result` handler when buffered
|
|
1028
|
+
* mid-turn messages need to start their own turn. The `sendUserText`
|
|
1029
|
+
* calls wake the SDK polling loop (priority="now" semantics) and
|
|
1030
|
+
* comprise the input for the new turn. Opens the card here rather
|
|
1031
|
+
* than deferring to init because the init for this batch will arrive
|
|
1032
|
+
* with `currentTurn` already set and bail. */
|
|
1033
|
+
private async drainMidTurnAndOpen(): Promise<void> {
|
|
1034
|
+
if (this.pendingMidTurnMsgs.length === 0) return
|
|
1035
|
+
const batch = this.pendingMidTurnMsgs
|
|
1036
|
+
this.pendingMidTurnMsgs = []
|
|
1037
|
+
this.openingTurn = true
|
|
1038
|
+
try {
|
|
1039
|
+
for (const msg of batch) {
|
|
1040
|
+
this.proc!.sendUserText(msg.wireText, msg.files)
|
|
1041
|
+
if (msg.msgId) {
|
|
1042
|
+
const rid = this.pendingReactionIds.get(msg.msgId) ?? ''
|
|
1043
|
+
this.currentBatchReactionIds.set(msg.msgId, rid)
|
|
1044
|
+
this.pendingReactionIds.delete(msg.msgId)
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
const last = batch[batch.length - 1]
|
|
1048
|
+
const userOpenId = last?.userOpenId ?? this.lastUserOpenId
|
|
1049
|
+
await this.openTurnCard(userOpenId, 'user_message')
|
|
1050
|
+
this.status = 'working'
|
|
1051
|
+
} finally {
|
|
1052
|
+
this.openingTurn = false
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
|
|
935
1056
|
private async openTurnCard(userOpenId: string, trigger: 'user_message' | 'scheduled'): Promise<void> {
|
|
936
1057
|
const turn = ++this.turnCounter
|
|
1058
|
+
log(`session "${this.sessionName}": openTurnCard turn=${turn} trigger=${trigger}`)
|
|
937
1059
|
const card = cards.mainConversationCard({
|
|
938
1060
|
sessionName: this.sessionName,
|
|
939
1061
|
turn,
|
|
@@ -954,11 +1076,10 @@ export class Session {
|
|
|
954
1076
|
this.chatId,
|
|
955
1077
|
'❌ 创建对话卡片失败 (Feishu SDK 重试 3 次后仍连不上)。你这条消息没能送到 Claude,请稍后重发。',
|
|
956
1078
|
)
|
|
957
|
-
//
|
|
958
|
-
//
|
|
959
|
-
//
|
|
960
|
-
//
|
|
961
|
-
this.proc?.sendInterrupt()
|
|
1079
|
+
// currentTurn left null as the failure signal. Caller decides
|
|
1080
|
+
// whether to sendInterrupt: onUserMessage's eager-open path
|
|
1081
|
+
// hasn't fed SDK yet so doesn't need to; the init handler has
|
|
1082
|
+
// (SDK started the turn itself) and must.
|
|
962
1083
|
return
|
|
963
1084
|
}
|
|
964
1085
|
let cardId: string
|
|
@@ -972,6 +1093,8 @@ export class Session {
|
|
|
972
1093
|
thinkingText: '',
|
|
973
1094
|
toolCount: 0,
|
|
974
1095
|
toolByUseId: new Map(),
|
|
1096
|
+
readBatches: new Map(),
|
|
1097
|
+
openReadBatchI: null,
|
|
975
1098
|
assistantSegmentCount: 0,
|
|
976
1099
|
currentAssistantSegmentId: null,
|
|
977
1100
|
currentAssistantText: '',
|
|
@@ -987,28 +1110,42 @@ export class Session {
|
|
|
987
1110
|
// and mutate `this.currentTurn` underfoot.
|
|
988
1111
|
private appendAssistant(delta: string): void {
|
|
989
1112
|
if (!this.currentTurn) return
|
|
990
|
-
|
|
991
|
-
|
|
1113
|
+
const turn = this.currentTurn
|
|
1114
|
+
if (!turn.currentAssistantSegmentId) {
|
|
1115
|
+
// New assistant segment opens a visual break — any prior Read run
|
|
1116
|
+
// is now visually separated from future Reads, so close the batch
|
|
1117
|
+
// window. Future Reads will start a fresh batch at a new i.
|
|
1118
|
+
turn.openReadBatchI = null
|
|
1119
|
+
const i = turn.assistantSegmentCount++
|
|
992
1120
|
const segId = cards.ELEMENTS.assistant(i)
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
void cardkit.addElement(
|
|
1121
|
+
turn.currentAssistantSegmentId = segId
|
|
1122
|
+
turn.currentAssistantText = ''
|
|
1123
|
+
void cardkit.addElement(turn.cardId, cards.assistantSegmentElement(i), {
|
|
996
1124
|
type: 'insert_before', targetElementId: cards.ELEMENTS.footer,
|
|
1125
|
+
}, () => {
|
|
1126
|
+
// addElement永久失败:reset segmentId 让下次 delta 重新创建
|
|
1127
|
+
// segment,否则后续 streamText 全都 PUT 到不存在的 element,
|
|
1128
|
+
// 整段 assistant text 在用户那看不到。守 segId 不变以防 turn
|
|
1129
|
+
// rotation 或 addTool 已经清掉了它(每次 addElement 闭包带的
|
|
1130
|
+
// 是自己创建那次的 segId,只清自己的)。
|
|
1131
|
+
if (turn.currentAssistantSegmentId === segId) {
|
|
1132
|
+
log(`session "${this.sessionName}": assistant segment ${segId} addElement failed — will retry on next delta`)
|
|
1133
|
+
turn.currentAssistantSegmentId = null
|
|
1134
|
+
turn.currentAssistantText = ''
|
|
1135
|
+
turn.segmentTexts.delete(segId)
|
|
1136
|
+
}
|
|
997
1137
|
})
|
|
998
1138
|
}
|
|
999
|
-
|
|
1000
|
-
const segId =
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
segId,
|
|
1005
|
-
this.currentTurn.currentAssistantText,
|
|
1006
|
-
)
|
|
1139
|
+
turn.currentAssistantText += delta
|
|
1140
|
+
const segId = turn.currentAssistantSegmentId
|
|
1141
|
+
if (!segId) return // addElement 已失败 reset,等下一次 delta 重建
|
|
1142
|
+
turn.segmentTexts.set(segId, turn.currentAssistantText)
|
|
1143
|
+
cardkit.streamTextThrottled(turn.cardId, segId, turn.currentAssistantText)
|
|
1007
1144
|
// Chat-list preview: tail of the latest assistant text. Feishu
|
|
1008
1145
|
// truncates anyway; ~60 chars is what shows on a typical phone
|
|
1009
1146
|
// preview line. patchSummaryThrottled is rate-limited on its own.
|
|
1010
|
-
const tail =
|
|
1011
|
-
cardkit.patchSummaryThrottled(
|
|
1147
|
+
const tail = turn.currentAssistantText.slice(-60)
|
|
1148
|
+
cardkit.patchSummaryThrottled(turn.cardId, tail)
|
|
1012
1149
|
}
|
|
1013
1150
|
|
|
1014
1151
|
private appendThinking(delta: string): void {
|
|
@@ -1039,7 +1176,37 @@ export class Session {
|
|
|
1039
1176
|
this.currentTurn.currentAssistantSegmentId = null
|
|
1040
1177
|
this.currentTurn.currentAssistantText = ''
|
|
1041
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
|
|
1042
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
|
+
}
|
|
1043
1210
|
this.currentTurn.toolByUseId.set(toolUseId, { i, name, input })
|
|
1044
1211
|
// AskUserQuestion is a client-side tool — daemon renders the choice
|
|
1045
1212
|
// UI in-line and supplies the tool_result itself once the user
|
|
@@ -1116,6 +1283,22 @@ export class Session {
|
|
|
1116
1283
|
// via toolCallElement would clobber the nice option-row layout
|
|
1117
1284
|
// with a generic JSON dump. Bail out; the panel is done.
|
|
1118
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
|
+
}
|
|
1119
1302
|
// Update the local todo mirror BEFORE rendering so the just-
|
|
1120
1303
|
// completed panel shows the new state too (e.g. a TaskCreate panel
|
|
1121
1304
|
// already lists the task it just created).
|