@leviyuan/lodestar 0.2.4 → 0.2.6
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 +3 -3
- package/daemon.ts +77 -5
- package/package.json +1 -1
- package/src/cardkit.ts +94 -25
- package/src/cards.ts +27 -10
- package/src/paths.ts +8 -0
- package/src/session.ts +193 -64
package/README.md
CHANGED
|
@@ -50,9 +50,9 @@ AI 不是帮手,是倍率。它放大的不是体力,是你——你的直觉、
|
|
|
50
50
|
| --- | --- |
|
|
51
51
|
| `hi` | 未运行时启动;运行中弹一张**状态卡片** |
|
|
52
52
|
| `stop` | 软打断当前 turn + 清空 type-ahead 排队;子进程保活,刚排队中的消息会被打 `CrossMark` 反应表示取消 |
|
|
53
|
-
| `kill` | 优雅关闭 Claude
|
|
54
|
-
| `restart` | 用上一次的 `sessionId` 重启会话(保留上下文) |
|
|
55
|
-
| `clear` |
|
|
53
|
+
| `kill` | 优雅关闭 Claude 进程;`sessionId` 仍记在磁盘,下次 `restart` 还能 resume |
|
|
54
|
+
| `restart` | 用上一次的 `sessionId` 重启会话(保留上下文);无进程时也能用,等于"恢复上一会话" |
|
|
55
|
+
| `clear` | 杀掉当前进程并启动一个全新 session(等价于 Claude Code 的 `/clear`);**无进程时无效** |
|
|
56
56
|
|
|
57
57
|
> 这五个词被全局保留:在群里发 "hi" 当问候也会触发控制台卡片,不会到 Claude 那边。换来的是手机上单手打字的便利。
|
|
58
58
|
|
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()
|
|
@@ -192,9 +216,6 @@ async function handleCardAction(data: any): Promise<any> {
|
|
|
192
216
|
case 'permission':
|
|
193
217
|
await session.onPermissionDecision(value.request_id, value.decision, userId)
|
|
194
218
|
return { toast: { type: value.decision === 'deny' ? 'error' : 'success', content: '已处理' } }
|
|
195
|
-
case 'console':
|
|
196
|
-
await session.onConsoleAction(value.action)
|
|
197
|
-
return { toast: { type: 'info', content: value.action } }
|
|
198
219
|
case 'menu':
|
|
199
220
|
await session.onUserMessage(`(menu choice ${value.choice + 1})`)
|
|
200
221
|
return { toast: { type: 'success', content: 'OK' } }
|
|
@@ -221,6 +242,55 @@ function fmt(m: any[]): string {
|
|
|
221
242
|
return m.map(x => typeof x === 'string' ? x : JSON.stringify(x)).join(' ')
|
|
222
243
|
}
|
|
223
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
|
+
|
|
224
294
|
async function boot(): Promise<void> {
|
|
225
295
|
log(`lodestar-daemon: pid ${process.pid} starting`)
|
|
226
296
|
feishu.loadSessionChatMap()
|
|
@@ -272,6 +342,8 @@ async function boot(): Promise<void> {
|
|
|
272
342
|
ws.start({ eventDispatcher: dispatcher })
|
|
273
343
|
log(`lodestar-daemon: WS started, watching ${feishu.chatNameCache.size} groups`)
|
|
274
344
|
|
|
345
|
+
startDebugSocket()
|
|
346
|
+
|
|
275
347
|
// Auto-revive sessions that were running when we last went down.
|
|
276
348
|
// Runs AFTER the WS is up so any 🔁 revive message lands in the
|
|
277
349
|
// right chat instead of disappearing into the void.
|
package/package.json
CHANGED
package/src/cardkit.ts
CHANGED
|
@@ -73,11 +73,64 @@ 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
|
+
): Promise<void> {
|
|
111
|
+
try {
|
|
112
|
+
await op()
|
|
113
|
+
return
|
|
114
|
+
} catch (e) {
|
|
115
|
+
if (!isStreamingClosed(e)) {
|
|
116
|
+
log(`cardkit ${label} ${cardId}: ${e}`)
|
|
117
|
+
return
|
|
118
|
+
}
|
|
119
|
+
log(`cardkit ${label} ${cardId}: streaming closed (300309) — reopening`)
|
|
120
|
+
}
|
|
121
|
+
try {
|
|
122
|
+
await reopenStreaming(cardId)
|
|
123
|
+
} catch (re) {
|
|
124
|
+
log(`cardkit STREAMING_REOPEN_FAILED ${cardId}: ${re}`)
|
|
125
|
+
return
|
|
126
|
+
}
|
|
127
|
+
try {
|
|
128
|
+
await op()
|
|
129
|
+
} catch (e2) {
|
|
130
|
+
log(`cardkit ${label} ${cardId} retry-after-reopen: ${e2}`)
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
81
134
|
/** Convert a sent interactive message into a card entity. */
|
|
82
135
|
export async function convertMessageToCard(messageId: string): Promise<string> {
|
|
83
136
|
const data = await call('POST', '/cards/id_convert', { message_id: messageId })
|
|
@@ -101,17 +154,17 @@ export async function createCardEntity(card: object): Promise<string> {
|
|
|
101
154
|
export function streamText(cardId: string, elementId: string, content: string): Promise<void> {
|
|
102
155
|
if (!content || !content.trim()) return Promise.resolve()
|
|
103
156
|
const s = state(cardId)
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
157
|
+
s.queue = s.queue.then(() => withReopenOnStreamingClosed(
|
|
158
|
+
cardId,
|
|
159
|
+
`streamText ${elementId}`,
|
|
160
|
+
async () => {
|
|
161
|
+
const seq = nextSeq(cardId)
|
|
107
162
|
await call('PUT', `/cards/${cardId}/elements/${elementId}/content`, {
|
|
108
163
|
content, sequence: seq,
|
|
109
164
|
})
|
|
110
165
|
s.lastSent.set(elementId, content)
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
}
|
|
114
|
-
})
|
|
166
|
+
},
|
|
167
|
+
))
|
|
115
168
|
return s.queue
|
|
116
169
|
}
|
|
117
170
|
|
|
@@ -155,46 +208,52 @@ export function addElement(
|
|
|
155
208
|
opts: { type?: 'append' | 'insert_before' | 'insert_after'; targetElementId?: string } = {},
|
|
156
209
|
): Promise<void> {
|
|
157
210
|
const s = state(cardId)
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
211
|
+
s.queue = s.queue.then(() => withReopenOnStreamingClosed(
|
|
212
|
+
cardId,
|
|
213
|
+
`addElement`,
|
|
214
|
+
async () => {
|
|
215
|
+
const seq = nextSeq(cardId)
|
|
161
216
|
await call('POST', `/cards/${cardId}/elements`, {
|
|
162
217
|
type: opts.type ?? 'append',
|
|
163
218
|
...(opts.targetElementId ? { target_element_id: opts.targetElementId } : {}),
|
|
164
219
|
elements: JSON.stringify([element]),
|
|
165
220
|
sequence: seq,
|
|
166
221
|
})
|
|
167
|
-
}
|
|
168
|
-
|
|
222
|
+
},
|
|
223
|
+
))
|
|
169
224
|
return s.queue
|
|
170
225
|
}
|
|
171
226
|
|
|
172
227
|
/** Replace an entire element (used to swap a tool placeholder with its result). */
|
|
173
228
|
export function replaceElement(cardId: string, elementId: string, element: object): Promise<void> {
|
|
174
229
|
const s = state(cardId)
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
230
|
+
s.queue = s.queue.then(() => withReopenOnStreamingClosed(
|
|
231
|
+
cardId,
|
|
232
|
+
`replaceElement ${elementId}`,
|
|
233
|
+
async () => {
|
|
234
|
+
const seq = nextSeq(cardId)
|
|
178
235
|
await call('PUT', `/cards/${cardId}/elements/${elementId}`, {
|
|
179
236
|
element: JSON.stringify(element),
|
|
180
237
|
sequence: seq,
|
|
181
238
|
})
|
|
182
|
-
}
|
|
183
|
-
|
|
239
|
+
},
|
|
240
|
+
))
|
|
184
241
|
return s.queue
|
|
185
242
|
}
|
|
186
243
|
|
|
187
244
|
/** Delete an element by id. */
|
|
188
245
|
export function deleteElement(cardId: string, elementId: string): Promise<void> {
|
|
189
246
|
const s = state(cardId)
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
247
|
+
s.queue = s.queue.then(() => withReopenOnStreamingClosed(
|
|
248
|
+
cardId,
|
|
249
|
+
`deleteElement ${elementId}`,
|
|
250
|
+
async () => {
|
|
251
|
+
const seq = nextSeq(cardId)
|
|
193
252
|
await call('DELETE', `/cards/${cardId}/elements/${elementId}`, {
|
|
194
253
|
sequence: seq,
|
|
195
254
|
})
|
|
196
|
-
}
|
|
197
|
-
|
|
255
|
+
},
|
|
256
|
+
))
|
|
198
257
|
return s.queue
|
|
199
258
|
}
|
|
200
259
|
|
|
@@ -235,12 +294,22 @@ export function cancelSummary(cardId: string): void {
|
|
|
235
294
|
summaryStates.delete(cardId)
|
|
236
295
|
}
|
|
237
296
|
|
|
238
|
-
/** Patch settings — used to flip streaming_mode off when a turn finishes.
|
|
297
|
+
/** Patch settings — used to flip streaming_mode off when a turn finishes.
|
|
298
|
+
*
|
|
299
|
+
* `nextSeq` is called inside the queued task (not at enqueue time) to
|
|
300
|
+
* match streamText/addElement/replaceElement/deleteElement above. Mixing
|
|
301
|
+
* call-time and execution-time seq allocation interleaves badly: a
|
|
302
|
+
* patchSettings enqueued right after a replaceElement would grab the
|
|
303
|
+
* smaller seq number, but the replaceElement's then-block would grab
|
|
304
|
+
* the larger one when it ran first, so the patchSettings PATCH lands
|
|
305
|
+
* with a stale seq and Feishu rejects 300317 "sequence number compare
|
|
306
|
+
* failed". Keeping all writes on execution-time allocation makes the
|
|
307
|
+
* seq order match the queue order. */
|
|
239
308
|
export function patchSettings(cardId: string, settings: object): Promise<void> {
|
|
240
309
|
const s = state(cardId)
|
|
241
|
-
const seq = nextSeq(cardId)
|
|
242
310
|
s.queue = s.queue.then(async () => {
|
|
243
311
|
try {
|
|
312
|
+
const seq = nextSeq(cardId)
|
|
244
313
|
await call('PATCH', `/cards/${cardId}/settings`, {
|
|
245
314
|
settings: JSON.stringify(settings),
|
|
246
315
|
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.
|
|
@@ -550,7 +574,6 @@ interface ConsoleOpts {
|
|
|
550
574
|
cumStats?: { tokens: number; costUsd: number; turns: number }
|
|
551
575
|
lastTurn?: { tokens: number; costUsd: number; durationMs: number }
|
|
552
576
|
sessionId?: string | null
|
|
553
|
-
hasSession: boolean
|
|
554
577
|
}
|
|
555
578
|
|
|
556
579
|
/** Format token counts as a compact human-readable string: 1,234 → 1.2K. */
|
|
@@ -656,7 +679,7 @@ export function consoleUsageContent(
|
|
|
656
679
|
export function consoleCard(opts: ConsoleOpts): object {
|
|
657
680
|
const {
|
|
658
681
|
sessionName, status, model, effort, uptimeMs, peers, usage,
|
|
659
|
-
contextTokens, contextLimit, cumStats, lastTurn, sessionId,
|
|
682
|
+
contextTokens, contextLimit, cumStats, lastTurn, sessionId,
|
|
660
683
|
} = opts
|
|
661
684
|
const statusEmoji = {
|
|
662
685
|
idle: '🟢 闲', working: '⚙️ 工作中', awaiting_permission: '🔐 等审批',
|
|
@@ -698,12 +721,6 @@ export function consoleCard(opts: ConsoleOpts): object {
|
|
|
698
721
|
lines.push(`**🆔 session** \`${sessionId.slice(0, 8)}…\``)
|
|
699
722
|
}
|
|
700
723
|
|
|
701
|
-
void hasSession // accept the field for caller compat; lifecycle is now
|
|
702
|
-
// driven by bare-word commands (`hi` / `kill` / `restart` / `clear`),
|
|
703
|
-
// not buttons — keeps the panel pure-readout and one-handed mobile-
|
|
704
|
-
// friendly. The 'refresh' / 'ls' actions stay in onConsoleAction for
|
|
705
|
-
// backward compat with any still-floating older cards in chat history.
|
|
706
|
-
|
|
707
724
|
const template = status === 'working' ? 'blue'
|
|
708
725
|
: status === 'awaiting_permission' ? 'orange'
|
|
709
726
|
: status === 'stopped' ? 'grey'
|
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
|
|
@@ -112,6 +128,15 @@ export class Session {
|
|
|
112
128
|
* turns from cron-fired scheduled wakeups: count > 0 ⇒ user;
|
|
113
129
|
* count === 0 ⇒ scheduled (and `initCount > 1`). */
|
|
114
130
|
private pendingUserMessageCount = 0
|
|
131
|
+
/** Mid-turn user messages buffered DAEMON-SIDE (not yet sendUserText'd
|
|
132
|
+
* to the SDK). Drained in the `result` handler by writing each to SDK
|
|
133
|
+
* stdin, which doubles as the `priority="now"` wake signal the SDK
|
|
134
|
+
* polling loop needs to start the next batch turn (the SDK won't
|
|
135
|
+
* auto-dequeue queued type-ahead msgs after `result` — confirmed via
|
|
136
|
+
* claude-code issue #39632). Buffering also keeps mid-turn msgs out
|
|
137
|
+
* of any AskUserQuestion `QUEUE remove` storm, since they were never
|
|
138
|
+
* in the SDK queue to begin with. */
|
|
139
|
+
private pendingMidTurnMsgs: Array<{ wireText: string; files: string[]; userOpenId: string; msgId: string }> = []
|
|
115
140
|
/** Most recent userOpenId seen via `onUserMessage`. Used only when a
|
|
116
141
|
* merged batch fires its init event and the daemon needs *some* open_id
|
|
117
142
|
* to scope the eventual `urgent_app` push — there's no obviously right
|
|
@@ -288,6 +313,7 @@ export class Session {
|
|
|
288
313
|
this.proc = null
|
|
289
314
|
this.currentTurn = null
|
|
290
315
|
this.pendingUserMessageCount = 0
|
|
316
|
+
this.pendingMidTurnMsgs = []
|
|
291
317
|
this.lastUserOpenId = ''
|
|
292
318
|
this.pendingReactionIds = new Map()
|
|
293
319
|
this.currentBatchReactionIds = new Map()
|
|
@@ -308,6 +334,7 @@ export class Session {
|
|
|
308
334
|
}
|
|
309
335
|
this.currentTurn = null
|
|
310
336
|
this.pendingUserMessageCount = 0
|
|
337
|
+
this.pendingMidTurnMsgs = []
|
|
311
338
|
this.lastUserOpenId = ''
|
|
312
339
|
this.pendingReactionIds = new Map()
|
|
313
340
|
this.currentBatchReactionIds = new Map()
|
|
@@ -372,11 +399,11 @@ export class Session {
|
|
|
372
399
|
// but the daemon can't reach into it directly; in practice the
|
|
373
400
|
// sendInterrupt() control_request causes the SDK to discard
|
|
374
401
|
// queued input alongside the in-flight call.
|
|
375
|
-
if (!this.currentTurn && this.pendingUserMessageCount === 0) {
|
|
402
|
+
if (!this.currentTurn && this.pendingUserMessageCount === 0 && this.pendingMidTurnMsgs.length === 0) {
|
|
376
403
|
await feishu.sendText(this.chatId, '⚪ 当前没有正在执行的 turn')
|
|
377
404
|
return true
|
|
378
405
|
}
|
|
379
|
-
log(`session "${this.sessionName}": stop command — interrupt + drop count=${this.pendingUserMessageCount}`)
|
|
406
|
+
log(`session "${this.sessionName}": stop command — interrupt + drop count=${this.pendingUserMessageCount} midBuffer=${this.pendingMidTurnMsgs.length}`)
|
|
380
407
|
// Cancelled queued msgs: remove the OneSecond (no longer waiting)
|
|
381
408
|
// and stamp a CrossMark (explicit cancelled state, distinct from
|
|
382
409
|
// a natural release where reactions just disappear). Cancelled
|
|
@@ -388,19 +415,42 @@ export class Session {
|
|
|
388
415
|
if (rid) void feishu.deleteReaction(msgId, rid)
|
|
389
416
|
void feishu.addReaction(msgId, 'CrossMark')
|
|
390
417
|
}
|
|
418
|
+
// Mid-turn buffer never reached SDK — cancel those too.
|
|
419
|
+
for (const msg of this.pendingMidTurnMsgs) {
|
|
420
|
+
if (msg.msgId) void feishu.addReaction(msg.msgId, 'CrossMark')
|
|
421
|
+
}
|
|
391
422
|
this.pendingUserMessageCount = 0
|
|
423
|
+
this.pendingMidTurnMsgs = []
|
|
392
424
|
this.lastUserOpenId = ''
|
|
393
425
|
this.pendingReactionIds = new Map()
|
|
394
426
|
this.currentBatchReactionIds = new Map()
|
|
395
427
|
this.interrupt()
|
|
428
|
+
// SDK 收到 interrupt 后不发 `result`,没人会触发 closeTurnCard。
|
|
429
|
+
// 这里主动封口,把 footer 改成 🛑 打断、折叠 thinking、把
|
|
430
|
+
// streaming_mode 翻回 false,否则卡片会僵在 `⏳ working…`。
|
|
431
|
+
await this.closeTurnCard('🛑 打断')
|
|
396
432
|
return true
|
|
397
433
|
case 'kill':
|
|
398
434
|
await this.stop()
|
|
399
435
|
return true
|
|
400
436
|
case 'restart':
|
|
437
|
+
// resume the prior conversation — kills the current proc (if
|
|
438
|
+
// any) and spawns a new one with `--resume <lastSessionId>`.
|
|
439
|
+
// If no process is running, this is how the user gets back the
|
|
440
|
+
// previous conversation after a `kill` or a daemon crash.
|
|
401
441
|
await this.restart(true)
|
|
402
442
|
return true
|
|
403
443
|
case 'clear':
|
|
444
|
+
// "throw away current conversation, start a new one". By design
|
|
445
|
+
// this only makes sense when there IS a current conversation:
|
|
446
|
+
// calling clear from stopped state is a no-op (user-confirmed
|
|
447
|
+
// 2026-05-16) — we don't want a stray `clear` to silently spawn
|
|
448
|
+
// a fresh session the user didn't ask for. To start from cold,
|
|
449
|
+
// use `hi`.
|
|
450
|
+
if (!this.isRunning()) {
|
|
451
|
+
await feishu.sendText(this.chatId, `⚪ session "${this.sessionName}" 当前未运行,clear 无效;用 \`hi\` 启动或 \`restart\` 恢复上一会话`)
|
|
452
|
+
return true
|
|
453
|
+
}
|
|
404
454
|
await this.restart(false)
|
|
405
455
|
return true
|
|
406
456
|
}
|
|
@@ -438,7 +488,6 @@ export class Session {
|
|
|
438
488
|
}
|
|
439
489
|
: undefined,
|
|
440
490
|
sessionId: this.proc?.sessionId ?? this.lastSessionId,
|
|
441
|
-
hasSession: this.isRunning(),
|
|
442
491
|
})
|
|
443
492
|
const messageId = await feishu.sendCard(this.chatId, card)
|
|
444
493
|
if (!messageId) return
|
|
@@ -519,8 +568,8 @@ export class Session {
|
|
|
519
568
|
// reaction) and the model would see e.g. "123" + "321" + "1"
|
|
520
569
|
// glued into a single string "1233211" (2026-05-16 accumulator
|
|
521
570
|
// bug).
|
|
522
|
-
const wasBusy = this.currentTurn !== null || this.openingTurn
|
|
523
|
-
|
|
571
|
+
const wasBusy = this.currentTurn !== null || this.openingTurn
|
|
572
|
+
|| this.pendingUserMessageCount > 0 || this.pendingMidTurnMsgs.length > 0
|
|
524
573
|
this.lastUserOpenId = userOpenId
|
|
525
574
|
// When the SDK will merge this msg with siblings into a multi-
|
|
526
575
|
// content user turn, wrap it in `<u>...</u>` so the model sees a
|
|
@@ -534,51 +583,53 @@ export class Session {
|
|
|
534
583
|
// skips the wrap — no sibling, no merge, no need. Contract
|
|
535
584
|
// declared in CHANNEL_INSTRUCTIONS.
|
|
536
585
|
const wireText = wasBusy ? `<u>${text}</u>` : text
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
// string is a no-op (deleteReaction guards against it), which is
|
|
543
|
-
// the right behavior if the add failed.
|
|
544
|
-
this.pendingReactionIds.set(msgId, '')
|
|
586
|
+
|
|
587
|
+
// Reaction helper: track the OneSecond reaction so deleteReaction can
|
|
588
|
+
// clear it later. Use empty-string sentinel until addReaction returns.
|
|
589
|
+
const trackReaction = (id: string) => {
|
|
590
|
+
this.pendingReactionIds.set(id, '')
|
|
545
591
|
void (async () => {
|
|
546
|
-
const rid = await feishu.addReaction(
|
|
592
|
+
const rid = await feishu.addReaction(id, 'OneSecond')
|
|
547
593
|
if (!rid) return
|
|
548
|
-
if (this.pendingReactionIds.has(
|
|
549
|
-
this.pendingReactionIds.set(
|
|
550
|
-
} else if (this.currentBatchReactionIds.has(
|
|
551
|
-
|
|
552
|
-
// addReaction was in flight — record the rid there so the
|
|
553
|
-
// batch's close-time deleteReaction sees it.
|
|
554
|
-
this.currentBatchReactionIds.set(msgId, rid)
|
|
594
|
+
if (this.pendingReactionIds.has(id)) {
|
|
595
|
+
this.pendingReactionIds.set(id, rid)
|
|
596
|
+
} else if (this.currentBatchReactionIds.has(id)) {
|
|
597
|
+
this.currentBatchReactionIds.set(id, rid)
|
|
555
598
|
} else {
|
|
556
|
-
// Orphan: both maps cleared
|
|
557
|
-
// them) before our add returned. The reaction is now stuck
|
|
558
|
-
// on the Feishu message with no one tracking it — delete
|
|
599
|
+
// Orphan: both maps cleared before our add returned. Delete
|
|
559
600
|
// directly so the user doesn't see a stale ⏳ forever.
|
|
560
|
-
|
|
561
|
-
// turn, 2 addReaction callbacks landed after close fired the
|
|
562
|
-
// release loop, those rids never made it back into either
|
|
563
|
-
// map → 2 stuck ⏳ in chat.)
|
|
564
|
-
void feishu.deleteReaction(msgId, rid)
|
|
601
|
+
void feishu.deleteReaction(id, rid)
|
|
565
602
|
}
|
|
566
603
|
})()
|
|
567
604
|
}
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
605
|
+
|
|
606
|
+
if (this.currentTurn !== null) {
|
|
607
|
+
// Mid-turn — BUFFER instead of immediate sendUserText. The SDK polling
|
|
608
|
+
// loop will not auto-dequeue queued type-ahead msgs after `result`
|
|
609
|
+
// (only `priority="now"` writes wake it — claude-code issue #39632),
|
|
610
|
+
// so writing here would leave the msg stuck until the next user msg
|
|
611
|
+
// arrives. Drain happens in the `result` handler, which both wakes
|
|
612
|
+
// the SDK and opens a fresh card for the new batch turn.
|
|
613
|
+
this.pendingMidTurnMsgs.push({ wireText, files, userOpenId, msgId })
|
|
614
|
+
if (msgId) trackReaction(msgId)
|
|
615
|
+
return
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
// No in-flight turn: send straight to SDK. This path handles
|
|
619
|
+
// - first message after spawn (init not yet fired)
|
|
620
|
+
// - bootstrap race (sibling msgs landing before init#1)
|
|
621
|
+
// - solo message after a prior turn has fully closed
|
|
622
|
+
this.proc!.sendUserText(wireText, files)
|
|
623
|
+
this.pendingUserMessageCount++
|
|
624
|
+
if (wasBusy && msgId) {
|
|
625
|
+
// Bootstrap race: the init handler will open the card for us; until
|
|
626
|
+
// then the OneSecond ⏳ is the only ack the user gets.
|
|
627
|
+
trackReaction(msgId)
|
|
628
|
+
}
|
|
629
|
+
if (!this.openingTurn && this.initCount >= 1) {
|
|
630
|
+
// Eager open: SDK is healthy and idle, open card now. Any extra
|
|
631
|
+
// messages arriving during the open's Feishu API await pile up in
|
|
632
|
+
// the count and the init handler batches them.
|
|
582
633
|
this.openingTurn = true
|
|
583
634
|
this.pendingUserMessageCount--
|
|
584
635
|
try {
|
|
@@ -642,7 +693,7 @@ export class Session {
|
|
|
642
693
|
* acknowledgement that their type-ahead message landed (the card
|
|
643
694
|
* doesn't open until the current turn finishes). */
|
|
644
695
|
isBusy(): boolean {
|
|
645
|
-
return this.currentTurn !== null || this.pendingUserMessageCount > 0
|
|
696
|
+
return this.currentTurn !== null || this.pendingUserMessageCount > 0 || this.pendingMidTurnMsgs.length > 0
|
|
646
697
|
}
|
|
647
698
|
|
|
648
699
|
/** Funnel an arbitrary chat message into the *current* question
|
|
@@ -777,19 +828,6 @@ export class Session {
|
|
|
777
828
|
}
|
|
778
829
|
}
|
|
779
830
|
|
|
780
|
-
async onConsoleAction(action: string): Promise<void> {
|
|
781
|
-
log(`session "${this.sessionName}": console action=${action}`)
|
|
782
|
-
switch (action) {
|
|
783
|
-
case 'interrupt': this.interrupt(); break
|
|
784
|
-
case 'clear': await this.restart(false); break
|
|
785
|
-
case 'stop': await this.stop(); break
|
|
786
|
-
case 'start': await this.start(); break
|
|
787
|
-
case 'resume': await this.restart(true); break
|
|
788
|
-
case 'refresh': await this.showConsole(); break
|
|
789
|
-
case 'ls': await feishu.sendText(this.chatId, `📁 ${this.workDir}`); break
|
|
790
|
-
}
|
|
791
|
-
}
|
|
792
|
-
|
|
793
831
|
// ── Wiring Claude → Feishu ─────────────────────────────────────────
|
|
794
832
|
private wireProc(p: ClaudeProcess): void {
|
|
795
833
|
p.on('init', () => {
|
|
@@ -801,6 +839,7 @@ export class Session {
|
|
|
801
839
|
feishu.bindSessionResume(this.sessionName, p.sessionId)
|
|
802
840
|
}
|
|
803
841
|
this.initCount++
|
|
842
|
+
log(`session "${this.sessionName}": SDK init#${this.initCount} pendingCount=${this.pendingUserMessageCount} midBuffer=${this.pendingMidTurnMsgs.length} currentTurn=${this.currentTurn ? 'yes' : 'no'} openingTurn=${this.openingTurn}`)
|
|
804
843
|
|
|
805
844
|
// Boot init (initCount === 1) is claimed by `onUserMessage`'s
|
|
806
845
|
// eager-open path — if a user message landed before the init
|
|
@@ -861,21 +900,28 @@ export class Session {
|
|
|
861
900
|
})
|
|
862
901
|
p.on('result', () => {
|
|
863
902
|
this.accumulateResultStats()
|
|
864
|
-
//
|
|
865
|
-
//
|
|
866
|
-
//
|
|
867
|
-
//
|
|
868
|
-
//
|
|
869
|
-
//
|
|
870
|
-
|
|
903
|
+
// Daemon-driven rotation: mid-turn msgs were buffered (not yet
|
|
904
|
+
// sent to SDK) — close the in-flight card with `📨 转交新卡` and
|
|
905
|
+
// drain the buffer in one shot. The drain writes each buffered
|
|
906
|
+
// msg to SDK stdin, which is the `priority="now"` wake the SDK
|
|
907
|
+
// polling loop needs (claude-code issue #39632) AND constitutes
|
|
908
|
+
// the input for the new batch turn. We open the new card here
|
|
909
|
+
// ourselves rather than waiting on init — the SDK init for this
|
|
910
|
+
// batch will fire shortly but `currentTurn` will already be set,
|
|
911
|
+
// so the init handler will return without double-opening.
|
|
912
|
+
const hasMidTurn = this.pendingMidTurnMsgs.length > 0
|
|
913
|
+
const suffix = hasMidTurn ? '📨 转交新卡' : undefined
|
|
914
|
+
log(`session "${this.sessionName}": SDK result midBuffer=${this.pendingMidTurnMsgs.length} suffix=${suffix ?? '<✅>'}`)
|
|
871
915
|
void this.closeTurnCard(suffix)
|
|
872
916
|
this.status = 'idle'
|
|
917
|
+
if (hasMidTurn) void this.drainMidTurnAndOpen()
|
|
873
918
|
})
|
|
874
919
|
p.on('exit', ({ code, signal, expected }: any) => {
|
|
875
920
|
log(`session "${this.sessionName}": claude exited code=${code} signal=${signal} expected=${expected}`)
|
|
876
921
|
this.proc = null
|
|
877
922
|
this.currentTurn = null
|
|
878
923
|
this.pendingUserMessageCount = 0
|
|
924
|
+
this.pendingMidTurnMsgs = []
|
|
879
925
|
this.lastUserOpenId = ''
|
|
880
926
|
this.pendingReactionIds = new Map()
|
|
881
927
|
this.currentBatchReactionIds = new Map()
|
|
@@ -932,8 +978,39 @@ export class Session {
|
|
|
932
978
|
return this.proc?.lastContextWindow ?? 200_000
|
|
933
979
|
}
|
|
934
980
|
|
|
981
|
+
/** Drain `pendingMidTurnMsgs` to the SDK and open a fresh card for the
|
|
982
|
+
* resulting batch turn. Called from the `result` handler when buffered
|
|
983
|
+
* mid-turn messages need to start their own turn. The `sendUserText`
|
|
984
|
+
* calls wake the SDK polling loop (priority="now" semantics) and
|
|
985
|
+
* comprise the input for the new turn. Opens the card here rather
|
|
986
|
+
* than deferring to init because the init for this batch will arrive
|
|
987
|
+
* with `currentTurn` already set and bail. */
|
|
988
|
+
private async drainMidTurnAndOpen(): Promise<void> {
|
|
989
|
+
if (this.pendingMidTurnMsgs.length === 0) return
|
|
990
|
+
const batch = this.pendingMidTurnMsgs
|
|
991
|
+
this.pendingMidTurnMsgs = []
|
|
992
|
+
this.openingTurn = true
|
|
993
|
+
try {
|
|
994
|
+
for (const msg of batch) {
|
|
995
|
+
this.proc!.sendUserText(msg.wireText, msg.files)
|
|
996
|
+
if (msg.msgId) {
|
|
997
|
+
const rid = this.pendingReactionIds.get(msg.msgId) ?? ''
|
|
998
|
+
this.currentBatchReactionIds.set(msg.msgId, rid)
|
|
999
|
+
this.pendingReactionIds.delete(msg.msgId)
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
const last = batch[batch.length - 1]
|
|
1003
|
+
const userOpenId = last?.userOpenId ?? this.lastUserOpenId
|
|
1004
|
+
await this.openTurnCard(userOpenId, 'user_message')
|
|
1005
|
+
this.status = 'working'
|
|
1006
|
+
} finally {
|
|
1007
|
+
this.openingTurn = false
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
|
|
935
1011
|
private async openTurnCard(userOpenId: string, trigger: 'user_message' | 'scheduled'): Promise<void> {
|
|
936
1012
|
const turn = ++this.turnCounter
|
|
1013
|
+
log(`session "${this.sessionName}": openTurnCard turn=${turn} trigger=${trigger}`)
|
|
937
1014
|
const card = cards.mainConversationCard({
|
|
938
1015
|
sessionName: this.sessionName,
|
|
939
1016
|
turn,
|
|
@@ -972,6 +1049,8 @@ export class Session {
|
|
|
972
1049
|
thinkingText: '',
|
|
973
1050
|
toolCount: 0,
|
|
974
1051
|
toolByUseId: new Map(),
|
|
1052
|
+
readBatches: new Map(),
|
|
1053
|
+
openReadBatchI: null,
|
|
975
1054
|
assistantSegmentCount: 0,
|
|
976
1055
|
currentAssistantSegmentId: null,
|
|
977
1056
|
currentAssistantText: '',
|
|
@@ -988,6 +1067,10 @@ export class Session {
|
|
|
988
1067
|
private appendAssistant(delta: string): void {
|
|
989
1068
|
if (!this.currentTurn) return
|
|
990
1069
|
if (!this.currentTurn.currentAssistantSegmentId) {
|
|
1070
|
+
// New assistant segment opens a visual break — any prior Read run
|
|
1071
|
+
// is now visually separated from future Reads, so close the batch
|
|
1072
|
+
// window. Future Reads will start a fresh batch at a new i.
|
|
1073
|
+
this.currentTurn.openReadBatchI = null
|
|
991
1074
|
const i = this.currentTurn.assistantSegmentCount++
|
|
992
1075
|
const segId = cards.ELEMENTS.assistant(i)
|
|
993
1076
|
this.currentTurn.currentAssistantSegmentId = segId
|
|
@@ -1039,7 +1122,37 @@ export class Session {
|
|
|
1039
1122
|
this.currentTurn.currentAssistantSegmentId = null
|
|
1040
1123
|
this.currentTurn.currentAssistantText = ''
|
|
1041
1124
|
}
|
|
1125
|
+
// Consecutive Read merger: if a Read run is already open, append to
|
|
1126
|
+
// its batch and re-render the panel instead of inserting a new one.
|
|
1127
|
+
// Any other tool name closes the run (handled below).
|
|
1128
|
+
if (name === 'Read' && this.currentTurn.openReadBatchI !== null) {
|
|
1129
|
+
const batchI = this.currentTurn.openReadBatchI
|
|
1130
|
+
const batch = this.currentTurn.readBatches.get(batchI)!
|
|
1131
|
+
const slot = batch.items.length
|
|
1132
|
+
batch.items.push({ toolUseId, input, output: null, isError: false })
|
|
1133
|
+
this.currentTurn.toolByUseId.set(toolUseId, { i: batchI, name, input, readBatchSlot: slot })
|
|
1134
|
+
const el = cards.readBatchElement(batchI, batch.items)
|
|
1135
|
+
void cardkit.replaceElement(this.currentTurn.cardId, cards.ELEMENTS.tool(batchI), el)
|
|
1136
|
+
return
|
|
1137
|
+
}
|
|
1138
|
+
if (name !== 'Read') this.currentTurn.openReadBatchI = null
|
|
1042
1139
|
const i = this.currentTurn.toolCount++
|
|
1140
|
+
if (name === 'Read') {
|
|
1141
|
+
// First Read of a potential run — render the existing single-tool
|
|
1142
|
+
// panel (which keeps the full file-contents dump on completion). If
|
|
1143
|
+
// a second Read arrives, completeTool/addTool will switch it to
|
|
1144
|
+
// `readBatchElement`.
|
|
1145
|
+
this.currentTurn.openReadBatchI = i
|
|
1146
|
+
this.currentTurn.readBatches.set(i, {
|
|
1147
|
+
items: [{ toolUseId, input, output: null, isError: false }],
|
|
1148
|
+
})
|
|
1149
|
+
this.currentTurn.toolByUseId.set(toolUseId, { i, name, input, readBatchSlot: 0 })
|
|
1150
|
+
const el = cards.toolCallElement(i, name, input, null, '⏳', undefined, undefined)
|
|
1151
|
+
void cardkit.addElement(this.currentTurn.cardId, el, {
|
|
1152
|
+
type: 'insert_before', targetElementId: cards.ELEMENTS.footer,
|
|
1153
|
+
})
|
|
1154
|
+
return
|
|
1155
|
+
}
|
|
1043
1156
|
this.currentTurn.toolByUseId.set(toolUseId, { i, name, input })
|
|
1044
1157
|
// AskUserQuestion is a client-side tool — daemon renders the choice
|
|
1045
1158
|
// UI in-line and supplies the tool_result itself once the user
|
|
@@ -1116,6 +1229,22 @@ export class Session {
|
|
|
1116
1229
|
// via toolCallElement would clobber the nice option-row layout
|
|
1117
1230
|
// with a generic JSON dump. Bail out; the panel is done.
|
|
1118
1231
|
if (meta.name === 'AskUserQuestion') return
|
|
1232
|
+
// Read batch path: update this row's status in the shared batch then
|
|
1233
|
+
// re-render. Single-item batches keep the original full-output panel
|
|
1234
|
+
// (file-contents dump); 2+ items switch to the compact `Read · N 次`
|
|
1235
|
+
// listing, which overwrites whatever was last drawn at this i.
|
|
1236
|
+
if (meta.name === 'Read' && meta.readBatchSlot != null) {
|
|
1237
|
+
const batch = this.currentTurn.readBatches.get(meta.i)
|
|
1238
|
+
if (batch) {
|
|
1239
|
+
const row = batch.items[meta.readBatchSlot]
|
|
1240
|
+
if (row) { row.output = output; row.isError = isError }
|
|
1241
|
+
const el = batch.items.length >= 2
|
|
1242
|
+
? cards.readBatchElement(meta.i, batch.items)
|
|
1243
|
+
: cards.toolCallElement(meta.i, meta.name, meta.input, output, isError ? '❌' : '✅', meta.resolvedNote, undefined)
|
|
1244
|
+
void cardkit.replaceElement(this.currentTurn.cardId, cards.ELEMENTS.tool(meta.i), el)
|
|
1245
|
+
}
|
|
1246
|
+
return
|
|
1247
|
+
}
|
|
1119
1248
|
// Update the local todo mirror BEFORE rendering so the just-
|
|
1120
1249
|
// completed panel shows the new state too (e.g. a TaskCreate panel
|
|
1121
1250
|
// already lists the task it just created).
|