@leviyuan/lodestar 0.1.6 → 0.1.8
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 +57 -6
- package/package.json +1 -1
- package/src/cardkit.ts +46 -0
- package/src/cards.ts +263 -93
- package/src/feishu.ts +76 -2
- package/src/paths.ts +6 -0
- package/src/session.ts +220 -98
- package/src/usage.ts +201 -0
package/src/feishu.ts
CHANGED
|
@@ -8,11 +8,11 @@
|
|
|
8
8
|
|
|
9
9
|
import * as lark from '@larksuiteoapi/node-sdk'
|
|
10
10
|
import { execSync } from 'node:child_process'
|
|
11
|
-
import { mkdirSync, readFileSync, realpathSync, statSync, writeFileSync } from 'node:fs'
|
|
11
|
+
import { existsSync, mkdirSync, readFileSync, realpathSync, statSync, unlinkSync, writeFileSync } from 'node:fs'
|
|
12
12
|
import { homedir } from 'node:os'
|
|
13
13
|
import { basename, extname, join } from 'node:path'
|
|
14
14
|
import { config } from './config'
|
|
15
|
-
import { DATA_DIR, INBOX_DIR, SESSION_CHAT_MAP_FILE, SESSION_RESUME_MAP_FILE } from './paths'
|
|
15
|
+
import { ALIVE_MARKER_FILE, DATA_DIR, INBOX_DIR, SESSION_CHAT_MAP_FILE, SESSION_RESUME_MAP_FILE } from './paths'
|
|
16
16
|
import { log } from './log'
|
|
17
17
|
|
|
18
18
|
const APP_ID = config.feishu.app_id
|
|
@@ -106,6 +106,36 @@ export function getSessionResume(sessionName: string): string | null {
|
|
|
106
106
|
return lastSessionIdByName.get(sessionName) ?? null
|
|
107
107
|
}
|
|
108
108
|
|
|
109
|
+
// ── Alive-on-shutdown marker ──────────────────────────────────────────
|
|
110
|
+
// Persists the list of session names that were still running when the
|
|
111
|
+
// daemon went down. Next boot reads + unlinks the file and auto-spawns
|
|
112
|
+
// (via session.restart(true)) only those — sessions that were already
|
|
113
|
+
// `stop`ped before shutdown are deliberately NOT in this list, so they
|
|
114
|
+
// stay stopped after restart.
|
|
115
|
+
|
|
116
|
+
export function writeAliveMarker(sessionNames: string[]): void {
|
|
117
|
+
try {
|
|
118
|
+
writeFileSync(ALIVE_MARKER_FILE, JSON.stringify(sessionNames, null, 2))
|
|
119
|
+
} catch (e) { log(`feishu: write alive marker failed: ${e}`) }
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Read + unlink in one shot — marker is single-use: revival should
|
|
123
|
+
* happen exactly once per boot, not re-run on every subsequent crash
|
|
124
|
+
* loop where systemd might keep re-launching us. */
|
|
125
|
+
export function readAndConsumeAliveMarker(): string[] {
|
|
126
|
+
if (!existsSync(ALIVE_MARKER_FILE)) return []
|
|
127
|
+
try {
|
|
128
|
+
const raw = readFileSync(ALIVE_MARKER_FILE, 'utf8')
|
|
129
|
+
try { unlinkSync(ALIVE_MARKER_FILE) } catch {}
|
|
130
|
+
const data = JSON.parse(raw)
|
|
131
|
+
return Array.isArray(data) ? data.filter((x: unknown): x is string => typeof x === 'string') : []
|
|
132
|
+
} catch (e) {
|
|
133
|
+
log(`feishu: read alive marker failed: ${e}`)
|
|
134
|
+
try { unlinkSync(ALIVE_MARKER_FILE) } catch {}
|
|
135
|
+
return []
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
109
139
|
export function chatIdForSession(sessionName: string): string | null {
|
|
110
140
|
const preferred = preferredChatForSession.get(sessionName)
|
|
111
141
|
if (preferred && chatNameCache.get(preferred) === sessionName) return preferred
|
|
@@ -174,6 +204,50 @@ export async function addReaction(messageId: string, emojiType: string): Promise
|
|
|
174
204
|
} catch (e) { log(`feishu: addReaction ${emojiType} on ${messageId} failed: ${e}`) }
|
|
175
205
|
}
|
|
176
206
|
|
|
207
|
+
// ── Urgent push ───────────────────────────────────────────────────────
|
|
208
|
+
/** Fire Feishu's "加急 — 应用内" push for an already-sent message.
|
|
209
|
+
* Bypasses chat-level mute and pops a full-screen prompt on the
|
|
210
|
+
* recipient's phone. Bot must be the original sender of the message
|
|
211
|
+
* AND must still be a member of the chat.
|
|
212
|
+
*
|
|
213
|
+
* Endpoint:
|
|
214
|
+
* PATCH /open-apis/im/v1/messages/{message_id}/urgent_app
|
|
215
|
+
* ?user_id_type=open_id
|
|
216
|
+
* body: { user_id_list: ["ou_..."] }
|
|
217
|
+
*
|
|
218
|
+
* Required app scope (either one):
|
|
219
|
+
* - `im:message.urgent` (「发送应用内加急消息」)
|
|
220
|
+
* - `im:message.urgent:app_send` (「…(历史版本)」)
|
|
221
|
+
*
|
|
222
|
+
* Limits: 50 QPS app-wide; per-recipient cap is 200 unread urgent
|
|
223
|
+
* messages (230023). No daily quota.
|
|
224
|
+
*
|
|
225
|
+
* Common error codes:
|
|
226
|
+
* 230012 — message not sent by this bot
|
|
227
|
+
* 230023 — recipient has 200 unread urgent already
|
|
228
|
+
* 230052 — missing scope / chat restricts urgent */
|
|
229
|
+
export async function urgentApp(messageId: string, openIds: string[]): Promise<void> {
|
|
230
|
+
if (!messageId) { log(`feishu: urgentApp skip — missing messageId`); return }
|
|
231
|
+
if (openIds.length === 0) { log(`feishu: urgentApp skip — empty openIds (msg=${messageId})`); return }
|
|
232
|
+
const token = await getTenantToken()
|
|
233
|
+
const url = `https://open.feishu.cn/open-apis/im/v1/messages/${messageId}/urgent_app?user_id_type=open_id`
|
|
234
|
+
try {
|
|
235
|
+
const res = await fetch(url, {
|
|
236
|
+
method: 'PATCH',
|
|
237
|
+
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
|
238
|
+
body: JSON.stringify({ user_id_list: openIds }),
|
|
239
|
+
})
|
|
240
|
+
const json = await res.json() as any
|
|
241
|
+
if (json?.code !== 0) {
|
|
242
|
+
log(`feishu: urgentApp ${messageId} code=${json?.code} msg=${json?.msg}`)
|
|
243
|
+
return
|
|
244
|
+
}
|
|
245
|
+
const invalid = json.data?.invalid_user_id_list ?? []
|
|
246
|
+
const delivered = openIds.length - invalid.length
|
|
247
|
+
log(`feishu: urgentApp ${messageId} ok — delivered=${delivered}${invalid.length ? ` invalid=${invalid.length}` : ''}`)
|
|
248
|
+
} catch (e) { log(`feishu: urgentApp ${messageId} failed: ${e}`) }
|
|
249
|
+
}
|
|
250
|
+
|
|
177
251
|
// ── Attachment download (image/file) ───────────────────────────────────
|
|
178
252
|
export async function downloadAttachment(
|
|
179
253
|
messageId: string, key: string, type: 'image' | 'file', name?: string,
|
package/src/paths.ts
CHANGED
|
@@ -40,4 +40,10 @@ export const PID_FILE = join(DATA_DIR, 'daemon.pid')
|
|
|
40
40
|
export const LOG_FILE = join(DATA_DIR, 'daemon.log')
|
|
41
41
|
export const SESSION_CHAT_MAP_FILE = join(DATA_DIR, 'session-chat-map.json')
|
|
42
42
|
export const SESSION_RESUME_MAP_FILE = join(DATA_DIR, 'session-resume-map.json')
|
|
43
|
+
/** Marker file written at shutdown listing the session names that
|
|
44
|
+
* were still alive. The next daemon boot reads it (and unlinks it)
|
|
45
|
+
* to auto-revive those sessions via `restart(true)` — bridges the
|
|
46
|
+
* gap between systemctl-restart killing every child Claude and
|
|
47
|
+
* Lodestar's "you have to send a message to re-spawn it" default. */
|
|
48
|
+
export const ALIVE_MARKER_FILE = join(DATA_DIR, 'alive-on-shutdown.json')
|
|
43
49
|
export const INBOX_DIR = join(DATA_DIR, 'inbox')
|
package/src/session.ts
CHANGED
|
@@ -16,9 +16,18 @@ import * as cards from './cards'
|
|
|
16
16
|
import * as feishu from './feishu'
|
|
17
17
|
import { log } from './log'
|
|
18
18
|
import { INBOX_DIR } from './paths'
|
|
19
|
+
import { readUsage } from './usage'
|
|
19
20
|
|
|
20
21
|
interface TurnState {
|
|
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
|
|
22
31
|
userText: string
|
|
23
32
|
thinkingText: string
|
|
24
33
|
toolCount: number
|
|
@@ -73,6 +82,14 @@ interface CumStats {
|
|
|
73
82
|
}
|
|
74
83
|
|
|
75
84
|
export class Session {
|
|
85
|
+
/** Process-wide registry of every Session ever constructed in this daemon.
|
|
86
|
+
* Used by the `hi` console panel to enumerate sibling sessions across
|
|
87
|
+
* Feishu groups. Sessions are never removed (matches the daemon's
|
|
88
|
+
* `sessions` map lifecycle — one Session per chat for the daemon's
|
|
89
|
+
* lifetime). Callers should filter on `isRunning()` when they only
|
|
90
|
+
* want currently-alive Claude processes. */
|
|
91
|
+
static readonly all: Set<Session> = new Set()
|
|
92
|
+
|
|
76
93
|
private proc: ClaudeProcess | null = null
|
|
77
94
|
private currentTurn: TurnState | null = null
|
|
78
95
|
private pendingPermissions = new Map<string, { toolUseId: string }>()
|
|
@@ -89,12 +106,19 @@ export class Session {
|
|
|
89
106
|
questions: cards.AskQuestion[]
|
|
90
107
|
i: number
|
|
91
108
|
requestId?: string
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
109
|
+
/** 累计答案 — key 是 question 原文 (SDK 把这条 record 格式
|
|
110
|
+
* 化进 tool_result), value 是用户选的 option label 或自定
|
|
111
|
+
* 义文字。全部 question 都答完时一并塞进 updatedInput.answers
|
|
112
|
+
* 发回 SDK。 */
|
|
113
|
+
answers: Record<string, string>
|
|
114
|
+
/** 已答详情 (按 question idx 索引),用来给历史面板和 terminal
|
|
115
|
+
* 状态画选中态。answers 同步累计,但这里多保留 customText /
|
|
116
|
+
* optionIdx 字段以便 UI 区分两种回答路径。 */
|
|
117
|
+
answered: Map<number, cards.AskAnswered>
|
|
118
|
+
/** 当前展示的 question idx。undefined 表示全部答完 (terminal)
|
|
119
|
+
* —— 这时若 requestId 已就位则 finalize;否则等 renderPermission
|
|
120
|
+
* 一来立即 finalize。 */
|
|
121
|
+
currentIdx?: number
|
|
98
122
|
}>()
|
|
99
123
|
private turnCounter = 0
|
|
100
124
|
// Last seen sessionId — preserved across `kill`/`stop` so a later
|
|
@@ -118,6 +142,7 @@ export class Session {
|
|
|
118
142
|
public readonly chatId: string,
|
|
119
143
|
private opts: SessionOpts = {},
|
|
120
144
|
) {
|
|
145
|
+
Session.all.add(this)
|
|
121
146
|
// Restore last-known claude session_id from disk so a daemon restart
|
|
122
147
|
// (systemctl, crash, watchdog) doesn't strand the user with a fresh
|
|
123
148
|
// conversation when they next type `restart`.
|
|
@@ -127,6 +152,16 @@ export class Session {
|
|
|
127
152
|
}
|
|
128
153
|
}
|
|
129
154
|
|
|
155
|
+
/** Minimal cross-chat snapshot for the `hi` peer-list section.
|
|
156
|
+
* `startedAt` stays private so this is the documented read path. */
|
|
157
|
+
peerSnapshot(): { name: string; status: Status; uptimeMs?: number } {
|
|
158
|
+
return {
|
|
159
|
+
name: this.sessionName,
|
|
160
|
+
status: this.status,
|
|
161
|
+
uptimeMs: this.startedAt ? (Date.now() - this.startedAt) : undefined,
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
130
165
|
get workDir(): string { return join(feishu.PROJECTS_ROOT, this.sessionName) }
|
|
131
166
|
isRunning(): boolean { return !!this.proc && this.proc.isAlive() }
|
|
132
167
|
|
|
@@ -168,12 +203,21 @@ export class Session {
|
|
|
168
203
|
await feishu.sendText(this.chatId, `⚪ session "${this.sessionName}" 当前未运行`)
|
|
169
204
|
return
|
|
170
205
|
}
|
|
171
|
-
|
|
172
|
-
|
|
206
|
+
// Flip lifecycle state SYNCHRONOUSLY before awaiting kill — daemon's
|
|
207
|
+
// SIGTERM cleanup snapshots `isRunning()` and if we're still mid-
|
|
208
|
+
// `proc.kill()` await it'll see proc!=null and write us into the
|
|
209
|
+
// alive marker, which makes the next boot auto-revive a session
|
|
210
|
+
// the user explicitly killed. Reordering the null-out fixes that
|
|
211
|
+
// race (bug observed 2026-05-15: `kill` immediately followed by
|
|
212
|
+
// `systemctl restart` revived the killed session on boot).
|
|
213
|
+
log(`session "${this.sessionName}": stop (${reason})`)
|
|
214
|
+
const proc = this.proc
|
|
215
|
+
this.lastSessionId = proc.sessionId ?? this.lastSessionId
|
|
173
216
|
this.proc = null
|
|
174
217
|
this.currentTurn = null
|
|
175
218
|
this.pendingPermissions.clear()
|
|
176
219
|
this.status = 'stopped'
|
|
220
|
+
await proc.kill()
|
|
177
221
|
await feishu.sendText(this.chatId, `🔴 ${reason} (session: ${this.sessionName})`)
|
|
178
222
|
}
|
|
179
223
|
|
|
@@ -257,6 +301,14 @@ export class Session {
|
|
|
257
301
|
model,
|
|
258
302
|
effort: 'max',
|
|
259
303
|
uptimeMs,
|
|
304
|
+
peers: [...Session.all]
|
|
305
|
+
.filter(s => s.isRunning())
|
|
306
|
+
.map(s => ({ ...s.peerSnapshot(), isCurrent: s === this })),
|
|
307
|
+
// Initial paint without usage → cards.ts renders the
|
|
308
|
+
// `_加载中…_` placeholder in the consoleUsage element. We patch
|
|
309
|
+
// it in below once readUsage() resolves (ccusage cold-call is
|
|
310
|
+
// ~5s; not worth blocking the panel on it).
|
|
311
|
+
usage: undefined,
|
|
260
312
|
contextTokens: this.currentContextTokens(),
|
|
261
313
|
cumStats: this.cumStats,
|
|
262
314
|
lastTurn: this.lastTurnDelta
|
|
@@ -269,7 +321,22 @@ export class Session {
|
|
|
269
321
|
sessionId: this.proc?.sessionId ?? this.lastSessionId,
|
|
270
322
|
hasSession: this.isRunning(),
|
|
271
323
|
})
|
|
272
|
-
await feishu.sendCard(this.chatId, card)
|
|
324
|
+
const messageId = await feishu.sendCard(this.chatId, card)
|
|
325
|
+
if (!messageId) return
|
|
326
|
+
// Patch the usage element asynchronously so the rest of the panel
|
|
327
|
+
// stays responsive. We don't await; failures are logged and the
|
|
328
|
+
// placeholder stays visible (no fallback fabrication).
|
|
329
|
+
void (async () => {
|
|
330
|
+
try {
|
|
331
|
+
const cardId = await cardkit.convertMessageToCard(messageId)
|
|
332
|
+
const usage = await readUsage()
|
|
333
|
+
await cardkit.replaceElement(cardId, cards.ELEMENTS.consoleUsage, {
|
|
334
|
+
tag: 'markdown',
|
|
335
|
+
element_id: cards.ELEMENTS.consoleUsage,
|
|
336
|
+
content: cards.consoleUsageContent(usage),
|
|
337
|
+
})
|
|
338
|
+
} catch (e) { log(`session "${this.sessionName}": consoleUsage patch failed: ${e}`) }
|
|
339
|
+
})()
|
|
273
340
|
}
|
|
274
341
|
|
|
275
342
|
interrupt(): void {
|
|
@@ -279,7 +346,7 @@ export class Session {
|
|
|
279
346
|
}
|
|
280
347
|
|
|
281
348
|
// ── Inbound from Feishu ────────────────────────────────────────────
|
|
282
|
-
async onUserMessage(text: string, files: string[] = []): Promise<void> {
|
|
349
|
+
async onUserMessage(text: string, files: string[] = [], userOpenId = ''): Promise<void> {
|
|
283
350
|
if (!this.isRunning()) {
|
|
284
351
|
const ok = await this.start()
|
|
285
352
|
if (!ok) return
|
|
@@ -289,7 +356,7 @@ export class Session {
|
|
|
289
356
|
this.proc!.sendInterrupt()
|
|
290
357
|
await this.closeTurnCard('🛑 用户打断')
|
|
291
358
|
}
|
|
292
|
-
await this.openTurnCard(text)
|
|
359
|
+
await this.openTurnCard(text, userOpenId)
|
|
293
360
|
this.proc!.sendUserText(text, files)
|
|
294
361
|
this.status = 'working'
|
|
295
362
|
}
|
|
@@ -340,11 +407,11 @@ export class Session {
|
|
|
340
407
|
return this.pendingAsks.size > 0
|
|
341
408
|
}
|
|
342
409
|
|
|
343
|
-
/** Funnel an arbitrary chat message into the
|
|
344
|
-
*
|
|
345
|
-
*
|
|
346
|
-
*
|
|
347
|
-
*
|
|
410
|
+
/** Funnel an arbitrary chat message into the *current* question
|
|
411
|
+
* of the oldest pending ask as a `customText` answer. Multi-
|
|
412
|
+
* question semantics: from the user's perspective, the chat
|
|
413
|
+
* input always answers whatever question is on screen right now
|
|
414
|
+
* (`pending.currentIdx`), and a new question slides in after. */
|
|
348
415
|
async onAskMessageAnswer(text: string, user: string): Promise<void> {
|
|
349
416
|
const firstEntry = this.pendingAsks.entries().next()
|
|
350
417
|
if (firstEntry.done) {
|
|
@@ -352,102 +419,121 @@ export class Session {
|
|
|
352
419
|
await this.onUserMessage(text)
|
|
353
420
|
return
|
|
354
421
|
}
|
|
355
|
-
const [toolUseId,
|
|
356
|
-
|
|
422
|
+
const [toolUseId, pending] = firstEntry.value
|
|
423
|
+
if (pending.currentIdx === undefined) {
|
|
424
|
+
log(`session "${this.sessionName}": pending ask ${toolUseId} already terminal — ignoring message`)
|
|
425
|
+
return
|
|
426
|
+
}
|
|
427
|
+
await this.onAskCustomAnswer(toolUseId, pending.currentIdx, text, user)
|
|
357
428
|
}
|
|
358
429
|
|
|
359
|
-
/** Click handler for an
|
|
360
|
-
*
|
|
361
|
-
*
|
|
362
|
-
*
|
|
430
|
+
/** Click handler for an option button. The click must target the
|
|
431
|
+
* question currently on screen (`pending.currentIdx`); a stale
|
|
432
|
+
* click (e.g. user clicked an older render before it swapped in
|
|
433
|
+
* the next question) is logged and dropped — better than double-
|
|
434
|
+
* answering. */
|
|
363
435
|
async onAskAnswer(toolUseId: string, questionIdx: number, optionIdx: number, user: string): Promise<void> {
|
|
364
436
|
const pending = this.pendingAsks.get(toolUseId)
|
|
365
437
|
if (!pending) { log(`session "${this.sessionName}": stray ask answer for ${toolUseId}`); return }
|
|
366
|
-
if (pending.
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
pending.deferredAnswer = { questionIdx, optionIdx, user }
|
|
370
|
-
log(`session "${this.sessionName}": ask answer deferred for ${toolUseId} (no requestId yet)`)
|
|
438
|
+
if (questionIdx !== pending.currentIdx) {
|
|
439
|
+
log(`session "${this.sessionName}": stale ask click q=${questionIdx} current=${pending.currentIdx}`)
|
|
440
|
+
return
|
|
371
441
|
}
|
|
442
|
+
this.advanceAsk(toolUseId, { optionIdx, user })
|
|
372
443
|
}
|
|
373
444
|
|
|
374
|
-
/**
|
|
375
|
-
*
|
|
376
|
-
* just routes a free-form string into `resolveAsk` instead of an
|
|
377
|
-
* option index. Empty/whitespace input is ignored (no answer
|
|
378
|
-
* sent, panel stays pending). */
|
|
445
|
+
/** Custom-text branch. Same staleness rule as onAskAnswer; empty
|
|
446
|
+
* input is silently ignored (panel stays pending). */
|
|
379
447
|
async onAskCustomAnswer(toolUseId: string, questionIdx: number, customText: string, user: string): Promise<void> {
|
|
380
448
|
const pending = this.pendingAsks.get(toolUseId)
|
|
381
449
|
if (!pending) { log(`session "${this.sessionName}": stray ask custom for ${toolUseId}`); return }
|
|
382
450
|
const trimmed = (customText ?? '').trim()
|
|
383
451
|
if (!trimmed) { log(`session "${this.sessionName}": empty custom answer, ignoring`); return }
|
|
384
|
-
if (pending.
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
pending.deferredAnswer = { questionIdx, customText: trimmed, user }
|
|
388
|
-
log(`session "${this.sessionName}": ask custom deferred for ${toolUseId} (no requestId yet)`)
|
|
452
|
+
if (questionIdx !== pending.currentIdx) {
|
|
453
|
+
log(`session "${this.sessionName}": stale ask custom q=${questionIdx} current=${pending.currentIdx}`)
|
|
454
|
+
return
|
|
389
455
|
}
|
|
456
|
+
this.advanceAsk(toolUseId, { customText: trimmed, user })
|
|
390
457
|
}
|
|
391
458
|
|
|
392
|
-
/**
|
|
393
|
-
*
|
|
394
|
-
* (
|
|
395
|
-
*
|
|
396
|
-
|
|
397
|
-
* all go through here. */
|
|
398
|
-
private resolveAsk(
|
|
459
|
+
/** Record an answer for the current question, advance the state
|
|
460
|
+
* machine, repaint. If every question is now answered, finalize
|
|
461
|
+
* (or defer the finalize until can_use_tool lands — the race is
|
|
462
|
+
* handled by renderPermission). */
|
|
463
|
+
private advanceAsk(
|
|
399
464
|
toolUseId: string,
|
|
400
|
-
|
|
401
|
-
answer: { questionIdx: number; optionIdx?: number; customText?: string; user: string },
|
|
465
|
+
answer: { optionIdx?: number; customText?: string; user: string },
|
|
402
466
|
): void {
|
|
403
467
|
const pending = this.pendingAsks.get(toolUseId)
|
|
404
|
-
if (!pending) return
|
|
405
|
-
const
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
// somehow set (shouldn't happen, but defensive).
|
|
413
|
-
let answerValue: string
|
|
414
|
-
if (answer.customText) {
|
|
415
|
-
answerValue = answer.customText
|
|
468
|
+
if (!pending || pending.currentIdx === undefined) return
|
|
469
|
+
const cur = pending.currentIdx
|
|
470
|
+
const q = pending.questions[cur]
|
|
471
|
+
if (!q) { log(`session "${this.sessionName}": advanceAsk currentIdx=${cur} out of range`); return }
|
|
472
|
+
// Resolve the literal answer value — custom text wins if both set.
|
|
473
|
+
let value: string
|
|
474
|
+
if (answer.customText !== undefined) {
|
|
475
|
+
value = answer.customText
|
|
416
476
|
} else if (answer.optionIdx !== undefined) {
|
|
417
477
|
const opt = q.options?.[answer.optionIdx]
|
|
418
|
-
if (!opt) {
|
|
419
|
-
|
|
420
|
-
return
|
|
421
|
-
}
|
|
422
|
-
answerValue = opt.label
|
|
478
|
+
if (!opt) { log(`session "${this.sessionName}": advanceAsk option ${answer.optionIdx} out of range`); return }
|
|
479
|
+
value = opt.label
|
|
423
480
|
} else {
|
|
424
|
-
log(`session "${this.sessionName}":
|
|
481
|
+
log(`session "${this.sessionName}": advanceAsk with neither customText nor optionIdx`)
|
|
425
482
|
return
|
|
426
483
|
}
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
// answered your questions: .").
|
|
433
|
-
const answers: Record<string, string> = { [q.question]: answerValue }
|
|
434
|
-
this.proc?.sendPermissionResponse(requestId, 'allow', {
|
|
435
|
-
updatedInput: { ...originalInput, answers },
|
|
484
|
+
pending.answers[q.question] = value
|
|
485
|
+
pending.answered.set(cur, {
|
|
486
|
+
optionIdx: answer.optionIdx,
|
|
487
|
+
customText: answer.customText,
|
|
488
|
+
user: answer.user,
|
|
436
489
|
})
|
|
437
|
-
|
|
438
|
-
|
|
490
|
+
// Next unanswered idx — linear from cur+1. Implementation
|
|
491
|
+
// always moves forward; we don't currently let users revisit a
|
|
492
|
+
// previous question (would need richer UI affordance for that).
|
|
493
|
+
const total = pending.questions.length
|
|
494
|
+
let nextIdx: number | undefined = undefined
|
|
495
|
+
for (let i = cur + 1; i < total; i++) {
|
|
496
|
+
if (!pending.answered.has(i)) { nextIdx = i; break }
|
|
497
|
+
}
|
|
498
|
+
pending.currentIdx = nextIdx
|
|
439
499
|
|
|
500
|
+
const turn = this.currentTurn
|
|
501
|
+
const meta = turn?.toolByUseId.get(toolUseId)
|
|
440
502
|
if (turn && meta) {
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
user: answer.user || '匿名',
|
|
447
|
-
})
|
|
503
|
+
const el = cards.askUserQuestionElement(
|
|
504
|
+
meta.i, toolUseId, pending.questions,
|
|
505
|
+
nextIdx === undefined ? '✅' : '🤔',
|
|
506
|
+
{ currentIdx: nextIdx, answered: pending.answered },
|
|
507
|
+
)
|
|
448
508
|
void cardkit.replaceElement(turn.cardId, cards.ELEMENTS.tool(meta.i), el)
|
|
449
509
|
}
|
|
450
510
|
|
|
511
|
+
if (nextIdx === undefined) {
|
|
512
|
+
// All done. Finalize iff we have the permission request id;
|
|
513
|
+
// otherwise renderPermission will pick it up when it arrives.
|
|
514
|
+
if (pending.requestId) this.finalizeAsk(toolUseId)
|
|
515
|
+
else log(`session "${this.sessionName}": ask ${toolUseId} all answered, waiting for can_use_tool`)
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/** Settle a fully-answered AskUserQuestion: emit the SDK allow
|
|
520
|
+
* with the full `answers` record folded into `updatedInput`,
|
|
521
|
+
* drop bookkeeping, restore status. The terminal panel paint was
|
|
522
|
+
* already done by the final advanceAsk; this is just protocol. */
|
|
523
|
+
private finalizeAsk(toolUseId: string): void {
|
|
524
|
+
const pending = this.pendingAsks.get(toolUseId)
|
|
525
|
+
if (!pending || !pending.requestId) return
|
|
526
|
+
const meta = this.currentTurn?.toolByUseId.get(toolUseId)
|
|
527
|
+
const originalInput = meta?.input ?? {}
|
|
528
|
+
this.proc?.sendPermissionResponse(pending.requestId, 'allow', {
|
|
529
|
+
updatedInput: { ...originalInput, answers: pending.answers },
|
|
530
|
+
})
|
|
531
|
+
this.pendingPermissions.delete(pending.requestId)
|
|
532
|
+
if (meta) {
|
|
533
|
+
meta.output = JSON.stringify({ answers: pending.answers })
|
|
534
|
+
meta.isError = false
|
|
535
|
+
}
|
|
536
|
+
this.pendingAsks.delete(toolUseId)
|
|
451
537
|
if (this.pendingPermissions.size === 0 && this.status === 'awaiting_permission') {
|
|
452
538
|
this.status = 'working'
|
|
453
539
|
}
|
|
@@ -543,7 +629,7 @@ export class Session {
|
|
|
543
629
|
return this.lastTurnDelta?.inputTokens ?? 0
|
|
544
630
|
}
|
|
545
631
|
|
|
546
|
-
private async openTurnCard(userText: string): Promise<void> {
|
|
632
|
+
private async openTurnCard(userText: string, userOpenId: string): Promise<void> {
|
|
547
633
|
const turn = ++this.turnCounter
|
|
548
634
|
const card = cards.mainConversationCard({
|
|
549
635
|
sessionName: this.sessionName,
|
|
@@ -558,6 +644,8 @@ export class Session {
|
|
|
558
644
|
catch (e) { log(`session "${this.sessionName}": id_convert failed: ${e}`); return }
|
|
559
645
|
this.currentTurn = {
|
|
560
646
|
cardId,
|
|
647
|
+
messageId,
|
|
648
|
+
userOpenId,
|
|
561
649
|
userText,
|
|
562
650
|
thinkingText: '',
|
|
563
651
|
toolCount: 0,
|
|
@@ -594,6 +682,11 @@ export class Session {
|
|
|
594
682
|
segId,
|
|
595
683
|
this.currentTurn.currentAssistantText,
|
|
596
684
|
)
|
|
685
|
+
// Chat-list preview: tail of the latest assistant text. Feishu
|
|
686
|
+
// truncates anyway; ~60 chars is what shows on a typical phone
|
|
687
|
+
// preview line. patchSummaryThrottled is rate-limited on its own.
|
|
688
|
+
const tail = this.currentTurn.currentAssistantText.slice(-60)
|
|
689
|
+
cardkit.patchSummaryThrottled(this.currentTurn.cardId, tail)
|
|
597
690
|
}
|
|
598
691
|
|
|
599
692
|
private appendThinking(delta: string): void {
|
|
@@ -634,12 +727,28 @@ export class Session {
|
|
|
634
727
|
// don't match the actual N options).
|
|
635
728
|
if (name === 'AskUserQuestion') {
|
|
636
729
|
const questions = Array.isArray(input?.questions) ? input.questions as cards.AskQuestion[] : []
|
|
637
|
-
|
|
638
|
-
const
|
|
730
|
+
const startIdx = questions.length > 0 ? 0 : undefined
|
|
731
|
+
const answered = new Map<number, cards.AskAnswered>()
|
|
732
|
+
this.pendingAsks.set(toolUseId, {
|
|
733
|
+
questions,
|
|
734
|
+
i,
|
|
735
|
+
answers: {},
|
|
736
|
+
answered,
|
|
737
|
+
currentIdx: startIdx,
|
|
738
|
+
})
|
|
739
|
+
const el = cards.askUserQuestionElement(i, toolUseId, questions, '🤔', {
|
|
740
|
+
currentIdx: startIdx,
|
|
741
|
+
answered,
|
|
742
|
+
})
|
|
639
743
|
void cardkit.addElement(this.currentTurn.cardId, el, {
|
|
640
744
|
type: 'insert_before',
|
|
641
745
|
targetElementId: cards.ELEMENTS.footer,
|
|
642
746
|
})
|
|
747
|
+
// Phone push — user has to come back and answer before Claude can
|
|
748
|
+
// continue. urgentApp no-ops when userOpenId is empty.
|
|
749
|
+
if (this.currentTurn.userOpenId && this.currentTurn.messageId) {
|
|
750
|
+
void feishu.urgentApp(this.currentTurn.messageId, [this.currentTurn.userOpenId])
|
|
751
|
+
}
|
|
643
752
|
return
|
|
644
753
|
}
|
|
645
754
|
// Pending Task* panels still show the *pre-op* todo mirror so users
|
|
@@ -799,30 +908,26 @@ export class Session {
|
|
|
799
908
|
if (meta.name === 'AskUserQuestion') {
|
|
800
909
|
const ask = this.pendingAsks.get(toolUseId)
|
|
801
910
|
if (!ask) {
|
|
802
|
-
// Defensive: addTool should have populated pendingAsks. If it
|
|
803
|
-
// didn't, fall back to a denial so Claude doesn't hang.
|
|
804
911
|
log(`session "${this.sessionName}": AskUserQuestion ${toolUseId} missing pendingAsk — deny`)
|
|
805
912
|
this.proc?.sendPermissionResponse(req.request_id, 'deny', { denyMessage: 'no pending ask' })
|
|
806
913
|
return
|
|
807
914
|
}
|
|
808
915
|
ask.requestId = req.request_id
|
|
809
916
|
this.pendingPermissions.set(req.request_id, { toolUseId })
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
questionIdx: d.questionIdx,
|
|
815
|
-
optionIdx: d.optionIdx,
|
|
816
|
-
customText: d.customText,
|
|
817
|
-
user: d.user,
|
|
818
|
-
})
|
|
819
|
-
}
|
|
917
|
+
// Fast-clicker race: the user may have answered every question
|
|
918
|
+
// while we were still waiting for can_use_tool to arrive. If so,
|
|
919
|
+
// advanceAsk parked the all-done state and we drain it now.
|
|
920
|
+
if (ask.currentIdx === undefined) this.finalizeAsk(toolUseId)
|
|
820
921
|
return
|
|
821
922
|
}
|
|
822
923
|
this.status = 'awaiting_permission'
|
|
823
924
|
this.pendingPermissions.set(req.request_id, { toolUseId })
|
|
824
925
|
const el = cards.toolCallPermissionElement(meta.i, meta.name, meta.input, req.request_id)
|
|
825
926
|
void cardkit.replaceElement(turn.cardId, cards.ELEMENTS.tool(meta.i), el)
|
|
927
|
+
// Phone push — Claude is blocked until the user approves/denies.
|
|
928
|
+
if (turn.userOpenId && turn.messageId) {
|
|
929
|
+
void feishu.urgentApp(turn.messageId, [turn.userOpenId])
|
|
930
|
+
}
|
|
826
931
|
}
|
|
827
932
|
|
|
828
933
|
private async closeTurnCard(suffix?: string): Promise<void> {
|
|
@@ -867,9 +972,26 @@ export class Session {
|
|
|
867
972
|
const sendNote = sendPaths.length ? ` · 📎 ${sendPaths.length}` : ''
|
|
868
973
|
const footer = `⏱ ${elapsed}s${suffix ? ' · ' + suffix : ''}${sendNote} · ✅ done`
|
|
869
974
|
await cardkit.streamText(cardId, cards.ELEMENTS.footer, footer)
|
|
870
|
-
|
|
975
|
+
// Final chat-list preview: clean finish shows "⏱ Xs · NK tokens";
|
|
976
|
+
// interrupted shows the suffix instead (no usage event landed).
|
|
977
|
+
// cancelSummary kills any in-flight throttled write so a stale
|
|
978
|
+
// mid-stream tail can't clobber this terminal summary.
|
|
979
|
+
cardkit.cancelSummary(cardId)
|
|
980
|
+
await cardkit.patchSettings(cardId, cards.streamingOffSettings({
|
|
981
|
+
durationSec: elapsed,
|
|
982
|
+
tokens: suffix ? undefined : this.lastTurnDelta?.tokens,
|
|
983
|
+
suffix,
|
|
984
|
+
}))
|
|
871
985
|
await cardkit.dispose(cardId)
|
|
872
986
|
|
|
987
|
+
// Phone push on clean turn close so the user knows Claude is done
|
|
988
|
+
// even with the chat backgrounded. Skip on interrupts (no real
|
|
989
|
+
// completion) and when we don't know who to ping. Fire-and-forget;
|
|
990
|
+
// urgent_app failures are non-fatal and already logged in feishu.ts.
|
|
991
|
+
if (!suffix && turn.userOpenId && turn.messageId) {
|
|
992
|
+
void feishu.urgentApp(turn.messageId, [turn.userOpenId])
|
|
993
|
+
}
|
|
994
|
+
|
|
873
995
|
// Fire uploads sequentially AFTER the card is sealed so each file
|
|
874
996
|
// posts as its own Feishu message below the conversation card.
|
|
875
997
|
// Path gate: workDir (Claude's project sandbox), the inbox where
|