@leviyuan/lodestar 0.1.1 → 0.1.3
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 +3 -0
- package/package.json +1 -1
- package/src/cards.ts +138 -7
- package/src/claude-process.ts +25 -0
- package/src/session.ts +241 -5
package/daemon.ts
CHANGED
|
@@ -140,6 +140,9 @@ async function handleCardAction(data: any): Promise<any> {
|
|
|
140
140
|
case 'menu':
|
|
141
141
|
await session.onUserMessage(`(menu choice ${value.choice + 1})`)
|
|
142
142
|
return { toast: { type: 'success', content: 'OK' } }
|
|
143
|
+
case 'ask':
|
|
144
|
+
await session.onAskAnswer(value.tool_use_id, value.question_idx ?? 0, value.option_idx, userId)
|
|
145
|
+
return { toast: { type: 'success', content: '已回答' } }
|
|
143
146
|
}
|
|
144
147
|
return { toast: { type: 'info', content: 'unknown action' } }
|
|
145
148
|
}
|
package/package.json
CHANGED
package/src/cards.ts
CHANGED
|
@@ -19,6 +19,44 @@ export const ELEMENTS = {
|
|
|
19
19
|
assistant: (i: number) => `assistant_${i}`,
|
|
20
20
|
} as const
|
|
21
21
|
|
|
22
|
+
/** Minimal projection of an SDK task — used by Session's local mirror,
|
|
23
|
+
* built incrementally from observed TaskCreate / TaskUpdate input+output
|
|
24
|
+
* pairs. Not authoritative (the SDK is the source of truth), but enough
|
|
25
|
+
* to render the "全部任务清单" footer on every Task* panel. */
|
|
26
|
+
export interface Todo {
|
|
27
|
+
id: number
|
|
28
|
+
subject?: string
|
|
29
|
+
description?: string
|
|
30
|
+
status: 'pending' | 'in_progress' | 'completed' | string
|
|
31
|
+
owner?: string
|
|
32
|
+
activeForm?: string
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function todoStatusIcon(s: string): string {
|
|
36
|
+
switch (s) {
|
|
37
|
+
case 'pending': return '☐'
|
|
38
|
+
case 'in_progress': return '🔄'
|
|
39
|
+
case 'completed': return '✅'
|
|
40
|
+
default: return '·'
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Render the session's full todo mirror as a markdown list. Empty list
|
|
45
|
+
* yields '' so callers can unconditionally concat. Sorted by numeric id
|
|
46
|
+
* so the order matches creation order regardless of Map iteration. */
|
|
47
|
+
function renderTodoList(todos: Todo[]): string {
|
|
48
|
+
if (!todos || todos.length === 0) return ''
|
|
49
|
+
const sorted = [...todos].sort((a, b) => a.id - b.id)
|
|
50
|
+
const lines = ['', '---', `**📋 当前任务清单(${sorted.length} 项)**`, '']
|
|
51
|
+
for (const t of sorted) {
|
|
52
|
+
const icon = todoStatusIcon(t.status)
|
|
53
|
+
const subject = t.subject ?? '(无 subject)'
|
|
54
|
+
const ownerTag = t.owner ? ` · ${t.owner}` : ''
|
|
55
|
+
lines.push(`- ${icon} **#${t.id}** ${subject}${ownerTag}`)
|
|
56
|
+
}
|
|
57
|
+
return lines.join('\n')
|
|
58
|
+
}
|
|
59
|
+
|
|
22
60
|
/** Single-line summary used as a collapsible-panel header for a tool call. */
|
|
23
61
|
export function summarizeToolInput(name: string, input: any): string {
|
|
24
62
|
if (!input || typeof input !== 'object') return ''
|
|
@@ -81,8 +119,10 @@ function summarizeTaskWorkflow(name: string, input: any): string {
|
|
|
81
119
|
/** Markdown body for Task* workflow tools — replaces the generic JSON
|
|
82
120
|
* dump with a human-readable description of the operation plus, once the
|
|
83
121
|
* tool result is in, the SDK's text reply (which already contains "Task
|
|
84
|
-
* #N created" / "Updated task #X" / a rendered list for TaskList).
|
|
85
|
-
|
|
122
|
+
* #N created" / "Updated task #X" / a rendered list for TaskList). When
|
|
123
|
+
* `todos` is non-empty, the full mirror is appended as a "📋 当前任务
|
|
124
|
+
* 清单" footer so every Task* panel doubles as a current-state readout. */
|
|
125
|
+
function renderTaskWorkflowBody(name: string, input: any, output: string | null, todos?: Todo[]): string {
|
|
86
126
|
const lines: string[] = []
|
|
87
127
|
switch (name) {
|
|
88
128
|
case 'TaskCreate':
|
|
@@ -118,7 +158,7 @@ function renderTaskWorkflowBody(name: string, input: any, output: string | null)
|
|
|
118
158
|
lines.push('**结果**')
|
|
119
159
|
lines.push(output.slice(0, 3000))
|
|
120
160
|
}
|
|
121
|
-
return lines.join('\n')
|
|
161
|
+
return lines.join('\n') + renderTodoList(todos ?? [])
|
|
122
162
|
}
|
|
123
163
|
|
|
124
164
|
interface MainCardOpts {
|
|
@@ -191,6 +231,10 @@ export function toolCallElement(
|
|
|
191
231
|
output: string | null,
|
|
192
232
|
status: '⏳' | '✅' | '❌' = '⏳',
|
|
193
233
|
resolvedNote?: string,
|
|
234
|
+
/** Session's full todo mirror — only rendered when the tool is a Task*
|
|
235
|
+
* workflow op. Other tools ignore it. Passed in by Session so every
|
|
236
|
+
* Task* panel shows the *current* state, not just this op's diff. */
|
|
237
|
+
todos?: Todo[],
|
|
194
238
|
): object {
|
|
195
239
|
const summary = summarizeToolInput(name, input)
|
|
196
240
|
const headerText = summary
|
|
@@ -198,11 +242,12 @@ export function toolCallElement(
|
|
|
198
242
|
: `${status} 🔧 ${name}`
|
|
199
243
|
const isTaskWorkflow = name.startsWith('Task') && name !== 'Task'
|
|
200
244
|
const noteBlock = resolvedNote ? `\n\n${resolvedNote}` : ''
|
|
201
|
-
// Task* gets a narrative body (operation + result
|
|
202
|
-
// JSON-input + raw-output split — generic dump is
|
|
203
|
-
// tools where users can't predict what fields
|
|
245
|
+
// Task* gets a narrative body (operation + result + current todo list),
|
|
246
|
+
// the rest keeps the JSON-input + raw-output split — generic dump is
|
|
247
|
+
// better for unfamiliar tools where users can't predict what fields
|
|
248
|
+
// matter.
|
|
204
249
|
const body = isTaskWorkflow
|
|
205
|
-
? renderTaskWorkflowBody(name, input, output) + noteBlock
|
|
250
|
+
? renderTaskWorkflowBody(name, input, output, todos) + noteBlock
|
|
206
251
|
: '```\n' + JSON.stringify(input ?? {}, null, 2).slice(0, 2000) + '\n```'
|
|
207
252
|
+ noteBlock
|
|
208
253
|
+ (output != null ? '\n---\n**output:**\n```\n' + output.slice(0, 3000) + '\n```' : '')
|
|
@@ -265,6 +310,92 @@ function permissionButtonColumn(label: string, type: string, requestId: string,
|
|
|
265
310
|
}
|
|
266
311
|
}
|
|
267
312
|
|
|
313
|
+
/** Schema of an AskUserQuestion question, projected to just the fields
|
|
314
|
+
* the panel needs. Mirrors the SDK tool's input — kept loose since the
|
|
315
|
+
* runtime guarantees it matches. */
|
|
316
|
+
export interface AskQuestion {
|
|
317
|
+
question: string
|
|
318
|
+
header?: string
|
|
319
|
+
multiSelect?: boolean
|
|
320
|
+
options: { label: string; description?: string }[]
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** Tool-panel renderer for `AskUserQuestion` — the SDK's structured
|
|
324
|
+
* multiple-choice question. Daemon takes over the client-side role:
|
|
325
|
+
* instead of letting the request fall through to the generic JSON
|
|
326
|
+
* dump (or worse, the permission flow that misappropriates it), we
|
|
327
|
+
* render each question with one button per option, callbacks tagged
|
|
328
|
+
* `kind:'ask'` so the Lark handler can route the answer back as a
|
|
329
|
+
* `tool_result`.
|
|
330
|
+
*
|
|
331
|
+
* Single-question is the common case; multi-question gets buttons on
|
|
332
|
+
* the first question only and a text-only listing for the rest (an
|
|
333
|
+
* acceptable limitation — these are rare in practice and we can lift
|
|
334
|
+
* it once the UX is validated). */
|
|
335
|
+
export function askUserQuestionElement(
|
|
336
|
+
i: number,
|
|
337
|
+
toolUseId: string,
|
|
338
|
+
questions: AskQuestion[],
|
|
339
|
+
status: '🤔' | '✅' | '❌' = '🤔',
|
|
340
|
+
resolvedNote?: string,
|
|
341
|
+
): object {
|
|
342
|
+
const primary = questions[0]
|
|
343
|
+
const headerTag = primary?.header ? ` · ${primary.header}` : ''
|
|
344
|
+
const headerText = `${status} 🤔 AskUserQuestion${headerTag}`
|
|
345
|
+
const bodyElements: any[] = []
|
|
346
|
+
if (primary) {
|
|
347
|
+
bodyElements.push({ tag: 'markdown', content: `**${primary.question}**` })
|
|
348
|
+
// Stack option buttons in a column_set — one button per option.
|
|
349
|
+
// Each carries `kind:'ask'` + the toolUseId + question/option idx
|
|
350
|
+
// so the daemon's card action handler can map a click back to
|
|
351
|
+
// exactly one (question, choice) pair.
|
|
352
|
+
bodyElements.push({
|
|
353
|
+
tag: 'column_set',
|
|
354
|
+
columns: primary.options.map((opt, optIdx) => ({
|
|
355
|
+
tag: 'column', width: 'weighted', weight: 1,
|
|
356
|
+
elements: [{
|
|
357
|
+
tag: 'button',
|
|
358
|
+
text: { tag: 'plain_text', content: opt.label },
|
|
359
|
+
type: 'default',
|
|
360
|
+
behaviors: [{
|
|
361
|
+
type: 'callback',
|
|
362
|
+
value: {
|
|
363
|
+
kind: 'ask',
|
|
364
|
+
tool_use_id: toolUseId,
|
|
365
|
+
question_idx: 0,
|
|
366
|
+
option_idx: optIdx,
|
|
367
|
+
},
|
|
368
|
+
}],
|
|
369
|
+
}],
|
|
370
|
+
})),
|
|
371
|
+
})
|
|
372
|
+
// Inline option descriptions below the buttons so the user can
|
|
373
|
+
// read context without hovering.
|
|
374
|
+
const descLines = primary.options
|
|
375
|
+
.map((o, idx) => o.description ? `- **${o.label}** — ${o.description}` : `- **${o.label}**`)
|
|
376
|
+
.join('\n')
|
|
377
|
+
if (descLines) bodyElements.push({ tag: 'markdown', content: descLines })
|
|
378
|
+
}
|
|
379
|
+
// Secondary questions get text-only treatment (TODO: multi-question
|
|
380
|
+
// panels when actually requested by a real prompt).
|
|
381
|
+
for (let qi = 1; qi < questions.length; qi++) {
|
|
382
|
+
const q = questions[qi]
|
|
383
|
+
const opts = q.options.map(o => ` - ${o.label}${o.description ? ` — ${o.description}` : ''}`).join('\n')
|
|
384
|
+
bodyElements.push({
|
|
385
|
+
tag: 'markdown',
|
|
386
|
+
content: `\n---\n**(其他问题 #${qi + 1}, 暂未支持回答)** ${q.question}\n${opts}`,
|
|
387
|
+
})
|
|
388
|
+
}
|
|
389
|
+
if (resolvedNote) bodyElements.push({ tag: 'markdown', content: resolvedNote })
|
|
390
|
+
return {
|
|
391
|
+
tag: 'collapsible_panel',
|
|
392
|
+
element_id: ELEMENTS.tool(i),
|
|
393
|
+
header: { title: { tag: 'plain_text', content: headerText } },
|
|
394
|
+
expanded: true,
|
|
395
|
+
elements: bodyElements,
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
268
399
|
interface ConsoleOpts {
|
|
269
400
|
sessionName: string
|
|
270
401
|
status: 'idle' | 'working' | 'awaiting_permission' | 'starting' | 'stopped'
|
package/src/claude-process.ts
CHANGED
|
@@ -312,6 +312,31 @@ export class ClaudeProcess extends EventEmitter {
|
|
|
312
312
|
})
|
|
313
313
|
}
|
|
314
314
|
|
|
315
|
+
/** Feed a `tool_result` back to Claude as if a tool finished — used
|
|
316
|
+
* by the daemon when WE (the client) own the tool's execution side,
|
|
317
|
+
* e.g. AskUserQuestion: the SDK emits `tool_use` and waits for the
|
|
318
|
+
* client to render UI, collect a choice, and supply the result here.
|
|
319
|
+
* `content` is whatever the tool's contract says — for
|
|
320
|
+
* AskUserQuestion this is a JSON string `{"answers": {...}}`.
|
|
321
|
+
*
|
|
322
|
+
* NOTE: this is the same `{type:'user'}` envelope as `sendUserText`,
|
|
323
|
+
* just with a `tool_result` content block instead of plain text. The
|
|
324
|
+
* SDK demultiplexes by `tool_use_id`. */
|
|
325
|
+
sendToolResult(toolUseId: string, content: string, isError = false): void {
|
|
326
|
+
this.write({
|
|
327
|
+
type: 'user',
|
|
328
|
+
message: {
|
|
329
|
+
role: 'user',
|
|
330
|
+
content: [{
|
|
331
|
+
type: 'tool_result',
|
|
332
|
+
tool_use_id: toolUseId,
|
|
333
|
+
content,
|
|
334
|
+
...(isError ? { is_error: true } : {}),
|
|
335
|
+
}],
|
|
336
|
+
},
|
|
337
|
+
})
|
|
338
|
+
}
|
|
339
|
+
|
|
315
340
|
sendHookResponse(requestId: string, output: object = {}): void {
|
|
316
341
|
this.write({
|
|
317
342
|
type: 'control_response',
|
package/src/session.ts
CHANGED
|
@@ -22,7 +22,18 @@ interface TurnState {
|
|
|
22
22
|
userText: string
|
|
23
23
|
thinkingText: string
|
|
24
24
|
toolCount: number
|
|
25
|
-
|
|
25
|
+
/** `output` / `isError` are filled in by completeTool — kept on the
|
|
26
|
+
* meta (instead of being thrown away after the first render) so a
|
|
27
|
+
* later Task* op can re-render every prior Task* panel with the
|
|
28
|
+
* latest todo mirror appended. */
|
|
29
|
+
toolByUseId: Map<string, {
|
|
30
|
+
i: number
|
|
31
|
+
name: string
|
|
32
|
+
input: any
|
|
33
|
+
resolvedNote?: string
|
|
34
|
+
output?: string
|
|
35
|
+
isError?: boolean
|
|
36
|
+
}>
|
|
26
37
|
assistantSegmentCount: number
|
|
27
38
|
currentAssistantSegmentId: string | null
|
|
28
39
|
currentAssistantText: string
|
|
@@ -65,6 +76,20 @@ export class Session {
|
|
|
65
76
|
private proc: ClaudeProcess | null = null
|
|
66
77
|
private currentTurn: TurnState | null = null
|
|
67
78
|
private pendingPermissions = new Map<string, { toolUseId: string }>()
|
|
79
|
+
/** Open AskUserQuestion tool calls — keyed by tool_use_id. The SDK
|
|
80
|
+
* routes AskUserQuestion through the can_use_tool flow even under
|
|
81
|
+
* bypass; we have to thread the permission `requestId` through here
|
|
82
|
+
* so onAskAnswer can answer it with `updatedInput.answers` populated.
|
|
83
|
+
* `deferredAnswer` covers the race where the user clicks a button
|
|
84
|
+
* BEFORE can_use_tool arrives (addTool fires on the assistant
|
|
85
|
+
* message; can_use_tool is a separate control_request that lands
|
|
86
|
+
* slightly later). */
|
|
87
|
+
private pendingAsks = new Map<string, {
|
|
88
|
+
questions: cards.AskQuestion[]
|
|
89
|
+
i: number
|
|
90
|
+
requestId?: string
|
|
91
|
+
deferredAnswer?: { questionIdx: number; optionIdx: number; user: string }
|
|
92
|
+
}>()
|
|
68
93
|
private turnCounter = 0
|
|
69
94
|
// Last seen sessionId — preserved across `kill`/`stop` so a later
|
|
70
95
|
// `restart` can resume the same Claude conversation even after the
|
|
@@ -73,6 +98,13 @@ export class Session {
|
|
|
73
98
|
private startedAt: number = 0
|
|
74
99
|
private cumStats: CumStats = { tokens: 0, costUsd: 0, turns: 0 }
|
|
75
100
|
private lastTurnDelta: LastTurnDelta | null = null
|
|
101
|
+
/** Local mirror of the SDK's task list — built incrementally from
|
|
102
|
+
* TaskCreate / TaskUpdate input+output pairs and rendered as a footer
|
|
103
|
+
* on every Task* panel. Lives for the lifetime of the Session
|
|
104
|
+
* instance; daemon restart wipes it (the SDK doesn't replay history).
|
|
105
|
+
* Not authoritative — Claude calling TaskList is still the source of
|
|
106
|
+
* truth; this mirror is purely for the panel readout. */
|
|
107
|
+
private currentTodos = new Map<number, cards.Todo>()
|
|
76
108
|
status: Status = 'stopped'
|
|
77
109
|
|
|
78
110
|
constructor(
|
|
@@ -270,13 +302,14 @@ export class Session {
|
|
|
270
302
|
const turn = this.currentTurn
|
|
271
303
|
const meta = turn?.toolByUseId.get(pending.toolUseId)
|
|
272
304
|
if (turn && meta) {
|
|
305
|
+
const todos = this.isTaskWorkflow(meta.name) ? this.todosArray() : undefined
|
|
273
306
|
if (decision === 'deny') {
|
|
274
|
-
const el = cards.toolCallElement(meta.i, meta.name, meta.input, `🚫 已拒绝 by ${user || '匿名'}`, '❌')
|
|
307
|
+
const el = cards.toolCallElement(meta.i, meta.name, meta.input, `🚫 已拒绝 by ${user || '匿名'}`, '❌', undefined, todos)
|
|
275
308
|
void cardkit.replaceElement(turn.cardId, cards.ELEMENTS.tool(meta.i), el)
|
|
276
309
|
} else {
|
|
277
310
|
const label = decision === 'allow_always' ? '始终允许' : '已允许'
|
|
278
311
|
meta.resolvedNote = `✅ **${label}** by ${user || '匿名'}`
|
|
279
|
-
const el = cards.toolCallElement(meta.i, meta.name, meta.input, null, '⏳', meta.resolvedNote)
|
|
312
|
+
const el = cards.toolCallElement(meta.i, meta.name, meta.input, null, '⏳', meta.resolvedNote, todos)
|
|
280
313
|
void cardkit.replaceElement(turn.cardId, cards.ELEMENTS.tool(meta.i), el)
|
|
281
314
|
}
|
|
282
315
|
}
|
|
@@ -293,6 +326,72 @@ export class Session {
|
|
|
293
326
|
}
|
|
294
327
|
}
|
|
295
328
|
|
|
329
|
+
/** Click handler for an AskUserQuestion option button. The actual
|
|
330
|
+
* "send answer to Claude" plumbing lives in `resolveAsk` — this
|
|
331
|
+
* wrapper just chooses between answering immediately (the common
|
|
332
|
+
* case: can_use_tool has already arrived and parked a requestId on
|
|
333
|
+
* the pendingAsk record) versus deferring (the rare race where the
|
|
334
|
+
* user clicks before can_use_tool lands). */
|
|
335
|
+
async onAskAnswer(toolUseId: string, questionIdx: number, optionIdx: number, user: string): Promise<void> {
|
|
336
|
+
const pending = this.pendingAsks.get(toolUseId)
|
|
337
|
+
if (!pending) { log(`session "${this.sessionName}": stray ask answer for ${toolUseId}`); return }
|
|
338
|
+
if (pending.requestId) {
|
|
339
|
+
this.resolveAsk(toolUseId, pending.requestId, questionIdx, optionIdx, user)
|
|
340
|
+
} else {
|
|
341
|
+
// can_use_tool hasn't landed yet — park the click. renderPermission
|
|
342
|
+
// will drain it the moment the request arrives.
|
|
343
|
+
pending.deferredAnswer = { questionIdx, optionIdx, user }
|
|
344
|
+
log(`session "${this.sessionName}": ask answer deferred for ${toolUseId} (no requestId yet)`)
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/** Settle an AskUserQuestion: emit the permission allow with the
|
|
349
|
+
* picked option folded into `updatedInput.answers` (this is the
|
|
350
|
+
* shape the SDK reads to synthesise the tool_result string), repaint
|
|
351
|
+
* the panel ✅, drop bookkeeping. Single source of truth — both the
|
|
352
|
+
* normal click path and the deferred-answer drain go through here. */
|
|
353
|
+
private resolveAsk(
|
|
354
|
+
toolUseId: string,
|
|
355
|
+
requestId: string,
|
|
356
|
+
questionIdx: number,
|
|
357
|
+
optionIdx: number,
|
|
358
|
+
user: string,
|
|
359
|
+
): void {
|
|
360
|
+
const pending = this.pendingAsks.get(toolUseId)
|
|
361
|
+
if (!pending) return
|
|
362
|
+
const q = pending.questions[questionIdx]
|
|
363
|
+
const opt = q?.options?.[optionIdx]
|
|
364
|
+
if (!q || !opt) {
|
|
365
|
+
log(`session "${this.sessionName}": ask answer out of range q=${questionIdx} o=${optionIdx}`)
|
|
366
|
+
return
|
|
367
|
+
}
|
|
368
|
+
const turn = this.currentTurn
|
|
369
|
+
const meta = turn?.toolByUseId.get(toolUseId)
|
|
370
|
+
const originalInput = meta?.input ?? {}
|
|
371
|
+
// SDK keys the answer record by the question's text — confirmed
|
|
372
|
+
// by the v0.1.2 jsonl trace (empty record formatted to "User has
|
|
373
|
+
// answered your questions: ."). Populate the key matching this
|
|
374
|
+
// question with the chosen option's label.
|
|
375
|
+
const answers: Record<string, string> = { [q.question]: opt.label }
|
|
376
|
+
this.proc?.sendPermissionResponse(requestId, 'allow', {
|
|
377
|
+
updatedInput: { ...originalInput, answers },
|
|
378
|
+
})
|
|
379
|
+
this.pendingPermissions.delete(requestId)
|
|
380
|
+
this.pendingAsks.delete(toolUseId)
|
|
381
|
+
|
|
382
|
+
if (turn && meta) {
|
|
383
|
+
meta.output = JSON.stringify({ answers })
|
|
384
|
+
meta.isError = false
|
|
385
|
+
const resolvedNote = `\n\n✅ **已回答** by ${user || '匿名'}: ${opt.label}`
|
|
386
|
+
const el = cards.askUserQuestionElement(meta.i, toolUseId, pending.questions, '✅', resolvedNote)
|
|
387
|
+
void cardkit.replaceElement(turn.cardId, cards.ELEMENTS.tool(meta.i), el)
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
if (this.pendingPermissions.size === 0 && this.status === 'awaiting_permission') {
|
|
391
|
+
this.status = 'working'
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
296
395
|
async onConsoleAction(action: string): Promise<void> {
|
|
297
396
|
log(`session "${this.sessionName}": console action=${action}`)
|
|
298
397
|
switch (action) {
|
|
@@ -446,6 +545,14 @@ export class Session {
|
|
|
446
545
|
)
|
|
447
546
|
}
|
|
448
547
|
|
|
548
|
+
private isTaskWorkflow(name: string): boolean {
|
|
549
|
+
return name.startsWith('Task') && name !== 'Task'
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
private todosArray(): cards.Todo[] {
|
|
553
|
+
return [...this.currentTodos.values()]
|
|
554
|
+
}
|
|
555
|
+
|
|
449
556
|
private addTool(toolUseId: string, name: string, input: any): void {
|
|
450
557
|
if (!this.currentTurn) return
|
|
451
558
|
// Close current assistant segment (if any) so the tool panel renders
|
|
@@ -458,7 +565,27 @@ export class Session {
|
|
|
458
565
|
}
|
|
459
566
|
const i = this.currentTurn.toolCount++
|
|
460
567
|
this.currentTurn.toolByUseId.set(toolUseId, { i, name, input })
|
|
461
|
-
|
|
568
|
+
// AskUserQuestion is a client-side tool — daemon renders the choice
|
|
569
|
+
// UI in-line and supplies the tool_result itself once the user
|
|
570
|
+
// clicks. Branch BEFORE the generic toolCallElement so we never
|
|
571
|
+
// fall through to a JSON dump or, worse, get clobbered by the
|
|
572
|
+
// permission flow (which would render 🔐 three-button buttons that
|
|
573
|
+
// don't match the actual N options).
|
|
574
|
+
if (name === 'AskUserQuestion') {
|
|
575
|
+
const questions = Array.isArray(input?.questions) ? input.questions as cards.AskQuestion[] : []
|
|
576
|
+
this.pendingAsks.set(toolUseId, { questions, i })
|
|
577
|
+
const el = cards.askUserQuestionElement(i, toolUseId, questions, '🤔')
|
|
578
|
+
void cardkit.addElement(this.currentTurn.cardId, el, {
|
|
579
|
+
type: 'insert_before',
|
|
580
|
+
targetElementId: cards.ELEMENTS.footer,
|
|
581
|
+
})
|
|
582
|
+
return
|
|
583
|
+
}
|
|
584
|
+
// Pending Task* panels still show the *pre-op* todo mirror so users
|
|
585
|
+
// can read the current state immediately, without waiting for the
|
|
586
|
+
// tool to return.
|
|
587
|
+
const todos = this.isTaskWorkflow(name) ? this.todosArray() : undefined
|
|
588
|
+
const el = cards.toolCallElement(i, name, input, null, '⏳', undefined, todos)
|
|
462
589
|
void cardkit.addElement(this.currentTurn.cardId, el, {
|
|
463
590
|
type: 'insert_before',
|
|
464
591
|
targetElementId: cards.ELEMENTS.footer,
|
|
@@ -474,8 +601,89 @@ export class Session {
|
|
|
474
601
|
: Array.isArray(content)
|
|
475
602
|
? content.map((c: any) => c?.text ?? JSON.stringify(c)).join('\n')
|
|
476
603
|
: JSON.stringify(content)
|
|
477
|
-
|
|
604
|
+
// Stash on the meta — every Task* op coming after this point may
|
|
605
|
+
// need to re-render this panel with a fresher todo footer, so we
|
|
606
|
+
// can't discard the output after the first paint.
|
|
607
|
+
meta.output = output
|
|
608
|
+
meta.isError = isError
|
|
609
|
+
// Update the local todo mirror BEFORE rendering so the just-
|
|
610
|
+
// completed panel shows the new state too (e.g. a TaskCreate panel
|
|
611
|
+
// already lists the task it just created).
|
|
612
|
+
if (!isError && this.isTaskWorkflow(meta.name)) {
|
|
613
|
+
this.updateTodosFromTask(meta.name, meta.input, output)
|
|
614
|
+
}
|
|
615
|
+
const todos = this.isTaskWorkflow(meta.name) ? this.todosArray() : undefined
|
|
616
|
+
const el = cards.toolCallElement(meta.i, meta.name, meta.input, output, isError ? '❌' : '✅', meta.resolvedNote, todos)
|
|
478
617
|
void cardkit.replaceElement(this.currentTurn.cardId, cards.ELEMENTS.tool(meta.i), el)
|
|
618
|
+
// Cascade the new mirror into every prior Task* panel in this turn
|
|
619
|
+
// so any expanded panel reflects the latest state, not the snapshot
|
|
620
|
+
// captured when that op ran.
|
|
621
|
+
if (!isError && this.isTaskWorkflow(meta.name)) {
|
|
622
|
+
this.refreshOtherTaskPanels(toolUseId)
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
/** Roll a single Task* op into the local mirror — best-effort. Output
|
|
627
|
+
* parsing is regex-based (the SDK returns plain text like "Task #7
|
|
628
|
+
* created successfully: …"), so unexpected variants are skipped
|
|
629
|
+
* silently rather than blowing up the panel render. */
|
|
630
|
+
private updateTodosFromTask(name: string, input: any, output: string): void {
|
|
631
|
+
switch (name) {
|
|
632
|
+
case 'TaskCreate': {
|
|
633
|
+
const m = output.match(/Task #(\d+) created/)
|
|
634
|
+
if (!m) return
|
|
635
|
+
const id = Number(m[1])
|
|
636
|
+
this.currentTodos.set(id, {
|
|
637
|
+
id,
|
|
638
|
+
subject: input.subject,
|
|
639
|
+
description: input.description,
|
|
640
|
+
activeForm: input.activeForm,
|
|
641
|
+
status: 'pending',
|
|
642
|
+
})
|
|
643
|
+
return
|
|
644
|
+
}
|
|
645
|
+
case 'TaskUpdate': {
|
|
646
|
+
const id = Number(input.taskId)
|
|
647
|
+
if (!Number.isFinite(id)) return
|
|
648
|
+
// status=deleted is the SDK's tombstone — drop from the mirror
|
|
649
|
+
// so the readout doesn't carry it forever. Server still keeps
|
|
650
|
+
// it; the mirror is just for the panel footer.
|
|
651
|
+
if (input.status === 'deleted') { this.currentTodos.delete(id); return }
|
|
652
|
+
const cur = this.currentTodos.get(id) ?? { id, status: 'pending' as const }
|
|
653
|
+
if (input.status) cur.status = input.status
|
|
654
|
+
if (input.subject) cur.subject = input.subject
|
|
655
|
+
if (input.description) cur.description = input.description
|
|
656
|
+
if (input.owner) cur.owner = input.owner
|
|
657
|
+
if (input.activeForm) cur.activeForm = input.activeForm
|
|
658
|
+
this.currentTodos.set(id, cur)
|
|
659
|
+
return
|
|
660
|
+
}
|
|
661
|
+
// TaskList / TaskGet / TaskStop / TaskOutput / TaskDelete:
|
|
662
|
+
// read-only or parse-heavy — skip mirror update. The panel will
|
|
663
|
+
// still render the SDK's textual result below the operation
|
|
664
|
+
// block, which is enough to disambiguate.
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
/** Re-render every Task* panel in the current turn (except the one
|
|
669
|
+
* that just landed — already up-to-date) so they all show the latest
|
|
670
|
+
* todo mirror in their footers. Cheap: ELEMENTS.tool(i) replace is
|
|
671
|
+
* queued through the per-card Promise chain like any other op. */
|
|
672
|
+
private refreshOtherTaskPanels(skipToolUseId: string): void {
|
|
673
|
+
if (!this.currentTurn) return
|
|
674
|
+
const todos = this.todosArray()
|
|
675
|
+
for (const [id, meta] of this.currentTurn.toolByUseId) {
|
|
676
|
+
if (id === skipToolUseId) continue
|
|
677
|
+
if (!this.isTaskWorkflow(meta.name)) continue
|
|
678
|
+
const status: '⏳' | '✅' | '❌' = meta.output === undefined
|
|
679
|
+
? '⏳'
|
|
680
|
+
: (meta.isError ? '❌' : '✅')
|
|
681
|
+
const el = cards.toolCallElement(
|
|
682
|
+
meta.i, meta.name, meta.input, meta.output ?? null,
|
|
683
|
+
status, meta.resolvedNote, todos,
|
|
684
|
+
)
|
|
685
|
+
void cardkit.replaceElement(this.currentTurn.cardId, cards.ELEMENTS.tool(meta.i), el)
|
|
686
|
+
}
|
|
479
687
|
}
|
|
480
688
|
|
|
481
689
|
/** Merge the permission ask into the existing tool element in the
|
|
@@ -511,6 +719,34 @@ export class Session {
|
|
|
511
719
|
this.proc?.sendPermissionResponse(req.request_id, 'deny', { denyMessage: 'unknown tool_use_id' })
|
|
512
720
|
return
|
|
513
721
|
}
|
|
722
|
+
// AskUserQuestion: SDK routes it through can_use_tool even under
|
|
723
|
+
// bypass. The PAYLOAD of "user has answered" is the permission
|
|
724
|
+
// response itself — specifically `updatedInput.answers`. So we
|
|
725
|
+
// CANNOT auto-allow here (that's the v0.1.2 bug: SDK got an empty
|
|
726
|
+
// answers map and immediately synthesised a "User has answered
|
|
727
|
+
// your questions: ." tool_result). Park the requestId on the
|
|
728
|
+
// pendingAsk record and wait for the user to click an option;
|
|
729
|
+
// onAskAnswer will then send allow + updatedInput.answers in one
|
|
730
|
+
// shot. If the user already clicked between addTool and now —
|
|
731
|
+
// the deferredAnswer slot — settle immediately.
|
|
732
|
+
if (meta.name === 'AskUserQuestion') {
|
|
733
|
+
const ask = this.pendingAsks.get(toolUseId)
|
|
734
|
+
if (!ask) {
|
|
735
|
+
// Defensive: addTool should have populated pendingAsks. If it
|
|
736
|
+
// didn't, fall back to a denial so Claude doesn't hang.
|
|
737
|
+
log(`session "${this.sessionName}": AskUserQuestion ${toolUseId} missing pendingAsk — deny`)
|
|
738
|
+
this.proc?.sendPermissionResponse(req.request_id, 'deny', { denyMessage: 'no pending ask' })
|
|
739
|
+
return
|
|
740
|
+
}
|
|
741
|
+
ask.requestId = req.request_id
|
|
742
|
+
this.pendingPermissions.set(req.request_id, { toolUseId })
|
|
743
|
+
if (ask.deferredAnswer) {
|
|
744
|
+
const { questionIdx, optionIdx, user } = ask.deferredAnswer
|
|
745
|
+
ask.deferredAnswer = undefined
|
|
746
|
+
this.resolveAsk(toolUseId, req.request_id, questionIdx, optionIdx, user)
|
|
747
|
+
}
|
|
748
|
+
return
|
|
749
|
+
}
|
|
514
750
|
this.status = 'awaiting_permission'
|
|
515
751
|
this.pendingPermissions.set(req.request_id, { toolUseId })
|
|
516
752
|
const el = cards.toolCallPermissionElement(meta.i, meta.name, meta.input, req.request_id)
|