@leviyuan/lodestar 0.1.0 → 0.1.2
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 +11 -7
- package/daemon.ts +4 -0
- package/package.json +1 -1
- package/src/cards.ts +330 -92
- package/src/claude-process.ts +83 -8
- package/src/feishu.ts +37 -18
- package/src/paths.ts +2 -0
- package/src/session.ts +340 -29
package/README.md
CHANGED
|
@@ -18,7 +18,7 @@ AI 不是帮手,是倍率。它放大的不是体力,是你——你的直
|
|
|
18
18
|
|
|
19
19
|
- 在群里发任意文字 — Claude 接管这一轮,回复以**流式打字机**实时渲染在一张飞书卡片里。
|
|
20
20
|
- 思考过程、每一次工具调用都在卡片里被收纳为**可展开折叠面板**:折起来是概述,展开是详情。你随时能审阅它在做什么。
|
|
21
|
-
-
|
|
21
|
+
- 需要授权的操作(执行命令、修改文件……)**就在原来那一格工具调用面板里**升级为 🔐 等审批状态,三颗按钮 `允许` / `始终允许` / `拒绝` 直接内嵌在面板里 — 不再弹独立卡片,决策结果与后续 output 串在同一条时序里。默认 `bypassPermissions`,所以这一格只在 SDK 真的拦下来时才出现。
|
|
22
22
|
- **图片、文件双向互传**:用户发到群里的图/文件,Claude 通过消息里的 `[file: /abs/path]` 提示就能读;Claude 想把文件发回来,在回复任意位置写 `[[send: /abs/path]]`,标记会被剥离,文件以独立消息出现在群里。出站路径限制在该会话的工作目录、`/tmp/lodestar-*` 与 inbox 之内,`/etc`、`~/.ssh`、`~/.config` 等敏感目录被白名单拒绝。
|
|
23
23
|
- 一轮跑完,卡片合上、可转发;下一句话开新一轮。
|
|
24
24
|
|
|
@@ -28,7 +28,7 @@ AI 不是帮手,是倍率。它放大的不是体力,是你——你的直
|
|
|
28
28
|
|
|
29
29
|
| 指令 | 行为 |
|
|
30
30
|
| --- | --- |
|
|
31
|
-
| `hi` |
|
|
31
|
+
| `hi` | 未运行时启动;运行中弹一张**状态卡片**(model · 上下文占用 · 累计 tokens/cost · 上一轮 delta · session id) |
|
|
32
32
|
| `kill` | 优雅关闭 Claude 进程;记住 `sessionId`,下次 `restart` 还能 resume |
|
|
33
33
|
| `restart` | 用上一次的 `sessionId` 重启会话(保留上下文) |
|
|
34
34
|
| `clear` | 杀掉进程并启动一个全新 session(等价于 Claude Code 的 `/clear`) |
|
|
@@ -68,10 +68,14 @@ projects_root = "~/" # 可选,新建群对应的项目目录会落到
|
|
|
68
68
|
### 3. 启动
|
|
69
69
|
|
|
70
70
|
```bash
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
71
|
+
bun install -g @leviyuan/lodestar
|
|
72
|
+
lodestar-daemon
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
或者一次性跑(无需全局安装):
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
bunx @leviyuan/lodestar
|
|
75
79
|
```
|
|
76
80
|
|
|
77
81
|
把机器人拉进任意飞书群,发一条消息——Claude 就上线了。
|
|
@@ -89,7 +93,7 @@ After=network-online.target
|
|
|
89
93
|
|
|
90
94
|
[Service]
|
|
91
95
|
Type=simple
|
|
92
|
-
ExecStart=/home/USER/.bun/bin/
|
|
96
|
+
ExecStart=/home/USER/.bun/bin/lodestar-daemon
|
|
93
97
|
Restart=always
|
|
94
98
|
RestartSec=3
|
|
95
99
|
|
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
|
}
|
|
@@ -152,6 +155,7 @@ function fmt(m: any[]): string {
|
|
|
152
155
|
async function boot(): Promise<void> {
|
|
153
156
|
log(`lodestar-daemon: pid ${process.pid} starting`)
|
|
154
157
|
feishu.loadSessionChatMap()
|
|
158
|
+
feishu.loadSessionResumeMap()
|
|
155
159
|
await feishu.refreshChatList()
|
|
156
160
|
setInterval(() => { void feishu.refreshChatList() }, 5 * 60 * 1000)
|
|
157
161
|
|
package/package.json
CHANGED
package/src/cards.ts
CHANGED
|
@@ -19,10 +19,55 @@ 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 ''
|
|
25
63
|
const truncate = (s: string, n: number) => s.length > n ? s.slice(0, n) + '…' : s
|
|
64
|
+
// Task workflow tools (TaskCreate / TaskUpdate / TaskList / ...) carry
|
|
65
|
+
// structured fields that summarize much better as natural language than
|
|
66
|
+
// as truncated JSON. Routed first so they don't fall through to the
|
|
67
|
+
// generic Agent/Task case below.
|
|
68
|
+
if (name.startsWith('Task') && name !== 'Task') {
|
|
69
|
+
return truncate(summarizeTaskWorkflow(name, input), 80)
|
|
70
|
+
}
|
|
26
71
|
switch (name) {
|
|
27
72
|
case 'Bash': return truncate(String(input.command ?? ''), 80)
|
|
28
73
|
case 'Read':
|
|
@@ -44,6 +89,78 @@ export function summarizeToolInput(name: string, input: any): string {
|
|
|
44
89
|
return ''
|
|
45
90
|
}
|
|
46
91
|
|
|
92
|
+
/** Header summary for Task* workflow tools — `Task` (singular) is the
|
|
93
|
+
* separate subagent-spawn tool and is handled above; everything else
|
|
94
|
+
* (TaskCreate / TaskUpdate / TaskList / TaskGet / TaskStop / TaskOutput /
|
|
95
|
+
* TaskDelete) summarises through here. */
|
|
96
|
+
function summarizeTaskWorkflow(name: string, input: any): string {
|
|
97
|
+
switch (name) {
|
|
98
|
+
case 'TaskCreate':
|
|
99
|
+
return `📝 创建: ${input.subject ?? '(无 subject)'}`
|
|
100
|
+
case 'TaskUpdate': {
|
|
101
|
+
const parts: string[] = []
|
|
102
|
+
if (input.status) parts.push(`→ ${input.status}`)
|
|
103
|
+
if (input.owner) parts.push(`owner=${input.owner}`)
|
|
104
|
+
if (input.subject) parts.push(`subject="${input.subject}"`)
|
|
105
|
+
if (input.addBlocks) parts.push(`blocks=[${(input.addBlocks ?? []).join(',')}]`)
|
|
106
|
+
if (input.addBlockedBy) parts.push(`blockedBy=[${(input.addBlockedBy ?? []).join(',')}]`)
|
|
107
|
+
const tail = parts.length ? ' ' + parts.join(', ') : ''
|
|
108
|
+
return `✏️ #${input.taskId ?? '?'}${tail}`
|
|
109
|
+
}
|
|
110
|
+
case 'TaskList': return '📋 查询任务列表'
|
|
111
|
+
case 'TaskGet': return `🔍 查询 #${input.taskId ?? '?'}`
|
|
112
|
+
case 'TaskStop': return `⏹ 停止 #${input.taskId ?? '?'}`
|
|
113
|
+
case 'TaskOutput': return `📤 取输出 #${input.taskId ?? '?'}`
|
|
114
|
+
case 'TaskDelete': return `🗑 删除 #${input.taskId ?? '?'}`
|
|
115
|
+
}
|
|
116
|
+
return name
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Markdown body for Task* workflow tools — replaces the generic JSON
|
|
120
|
+
* dump with a human-readable description of the operation plus, once the
|
|
121
|
+
* tool result is in, the SDK's text reply (which already contains "Task
|
|
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 {
|
|
126
|
+
const lines: string[] = []
|
|
127
|
+
switch (name) {
|
|
128
|
+
case 'TaskCreate':
|
|
129
|
+
lines.push(`**📝 创建任务**`)
|
|
130
|
+
if (input.subject) lines.push(`- subject: ${input.subject}`)
|
|
131
|
+
if (input.description) lines.push(`- 描述: ${input.description}`)
|
|
132
|
+
if (input.activeForm) lines.push(`- 进行时: ${input.activeForm}`)
|
|
133
|
+
break
|
|
134
|
+
case 'TaskUpdate': {
|
|
135
|
+
lines.push(`**✏️ 更新 #${input.taskId ?? '?'}**`)
|
|
136
|
+
if (input.status) lines.push(`- status → \`${input.status}\``)
|
|
137
|
+
if (input.subject) lines.push(`- subject: ${input.subject}`)
|
|
138
|
+
if (input.description) lines.push(`- description: ${input.description}`)
|
|
139
|
+
if (input.owner) lines.push(`- owner: ${input.owner}`)
|
|
140
|
+
if (input.activeForm) lines.push(`- 进行时: ${input.activeForm}`)
|
|
141
|
+
if (input.addBlocks) lines.push(`- blocks → ${(input.addBlocks).join(', ')}`)
|
|
142
|
+
if (input.addBlockedBy) lines.push(`- blockedBy → ${(input.addBlockedBy).join(', ')}`)
|
|
143
|
+
if (input.metadata) lines.push(`- metadata: \`${JSON.stringify(input.metadata)}\``)
|
|
144
|
+
break
|
|
145
|
+
}
|
|
146
|
+
case 'TaskList': lines.push('**📋 查询当前任务清单**'); break
|
|
147
|
+
case 'TaskGet': lines.push(`**🔍 查询 #${input.taskId ?? '?'}**`); break
|
|
148
|
+
case 'TaskStop': lines.push(`**⏹ 停止 #${input.taskId ?? '?'}**`); break
|
|
149
|
+
case 'TaskOutput': lines.push(`**📤 取 #${input.taskId ?? '?'} 输出**`); break
|
|
150
|
+
case 'TaskDelete': lines.push(`**🗑 删除 #${input.taskId ?? '?'}**`); break
|
|
151
|
+
default:
|
|
152
|
+
lines.push(`**${name}**`)
|
|
153
|
+
lines.push('```\n' + JSON.stringify(input ?? {}, null, 2).slice(0, 1000) + '\n```')
|
|
154
|
+
}
|
|
155
|
+
if (output != null) {
|
|
156
|
+
lines.push('')
|
|
157
|
+
lines.push('---')
|
|
158
|
+
lines.push('**结果**')
|
|
159
|
+
lines.push(output.slice(0, 3000))
|
|
160
|
+
}
|
|
161
|
+
return lines.join('\n') + renderTodoList(todos ?? [])
|
|
162
|
+
}
|
|
163
|
+
|
|
47
164
|
interface MainCardOpts {
|
|
48
165
|
sessionName: string
|
|
49
166
|
turn: number
|
|
@@ -100,70 +217,84 @@ export function thinkingCollapsedPanel(fullText: string): object {
|
|
|
100
217
|
}
|
|
101
218
|
}
|
|
102
219
|
|
|
103
|
-
/** Element to insert for each tool call.
|
|
220
|
+
/** Element to insert for each tool call. Expandable for big results.
|
|
104
221
|
*
|
|
105
222
|
* Header is a one-line summary: status + name + summarized input.
|
|
106
|
-
* Body holds the full input + (after completion) the full output.
|
|
223
|
+
* Body holds the full input + (after completion) the full output.
|
|
224
|
+
* `resolvedNote` is an optional one-liner appended below the input —
|
|
225
|
+
* used to surface "✅ 允许 by Alice" inline after a permission decision
|
|
226
|
+
* lands but before the actual tool execution completes. */
|
|
107
227
|
export function toolCallElement(
|
|
108
228
|
i: number,
|
|
109
229
|
name: string,
|
|
110
230
|
input: any,
|
|
111
231
|
output: string | null,
|
|
112
232
|
status: '⏳' | '✅' | '❌' = '⏳',
|
|
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[],
|
|
113
238
|
): object {
|
|
114
239
|
const summary = summarizeToolInput(name, input)
|
|
115
240
|
const headerText = summary
|
|
116
241
|
? `${status} 🔧 ${name}: ${summary}`
|
|
117
242
|
: `${status} 🔧 ${name}`
|
|
118
|
-
const
|
|
119
|
-
const
|
|
120
|
-
|
|
121
|
-
|
|
243
|
+
const isTaskWorkflow = name.startsWith('Task') && name !== 'Task'
|
|
244
|
+
const noteBlock = resolvedNote ? `\n\n${resolvedNote}` : ''
|
|
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.
|
|
249
|
+
const body = isTaskWorkflow
|
|
250
|
+
? renderTaskWorkflowBody(name, input, output, todos) + noteBlock
|
|
251
|
+
: '```\n' + JSON.stringify(input ?? {}, null, 2).slice(0, 2000) + '\n```'
|
|
252
|
+
+ noteBlock
|
|
253
|
+
+ (output != null ? '\n---\n**output:**\n```\n' + output.slice(0, 3000) + '\n```' : '')
|
|
122
254
|
return {
|
|
123
255
|
tag: 'collapsible_panel',
|
|
124
256
|
element_id: ELEMENTS.tool(i),
|
|
125
257
|
header: { title: { tag: 'plain_text', content: headerText } },
|
|
126
258
|
expanded: false,
|
|
127
259
|
elements: [
|
|
128
|
-
{ tag: 'markdown', content:
|
|
260
|
+
{ tag: 'markdown', content: body },
|
|
129
261
|
],
|
|
130
262
|
}
|
|
131
263
|
}
|
|
132
264
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
265
|
+
/** Same tool panel as `toolCallElement`, but with the 🔐 status and
|
|
266
|
+
* three inline action buttons (allow / allow_always / deny). Expanded
|
|
267
|
+
* by default so the user can read the request without clicking through.
|
|
268
|
+
* This is the "merge into tool panel" UX — the permission decision
|
|
269
|
+
* lives on the same row as the tool call instead of as a separate
|
|
270
|
+
* floating card. */
|
|
271
|
+
export function toolCallPermissionElement(
|
|
272
|
+
i: number,
|
|
273
|
+
name: string,
|
|
274
|
+
input: any,
|
|
275
|
+
requestId: string,
|
|
276
|
+
): object {
|
|
277
|
+
const summary = summarizeToolInput(name, input)
|
|
278
|
+
const headerText = summary
|
|
279
|
+
? `🔐 等审批 · ${name}: ${summary}`
|
|
280
|
+
: `🔐 等审批 · ${name}`
|
|
281
|
+
const inputBlock = '```\n' + JSON.stringify(input ?? {}, null, 2).slice(0, 2000) + '\n```'
|
|
145
282
|
return {
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
header: {
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
permissionButtonColumn('♾️ 始终允许', 'default', requestId, 'allow_always'),
|
|
162
|
-
permissionButtonColumn('❌ 拒绝', 'danger', requestId, 'deny'),
|
|
163
|
-
],
|
|
164
|
-
},
|
|
165
|
-
],
|
|
166
|
-
},
|
|
283
|
+
tag: 'collapsible_panel',
|
|
284
|
+
element_id: ELEMENTS.tool(i),
|
|
285
|
+
header: { title: { tag: 'plain_text', content: headerText } },
|
|
286
|
+
expanded: true,
|
|
287
|
+
elements: [
|
|
288
|
+
{ tag: 'markdown', content: inputBlock },
|
|
289
|
+
{
|
|
290
|
+
tag: 'column_set',
|
|
291
|
+
columns: [
|
|
292
|
+
permissionButtonColumn('✅ 允许', 'primary', requestId, 'allow'),
|
|
293
|
+
permissionButtonColumn('♾️ 始终允许', 'default', requestId, 'allow_always'),
|
|
294
|
+
permissionButtonColumn('❌ 拒绝', 'danger', requestId, 'deny'),
|
|
295
|
+
],
|
|
296
|
+
},
|
|
297
|
+
],
|
|
167
298
|
}
|
|
168
299
|
}
|
|
169
300
|
|
|
@@ -179,26 +310,89 @@ function permissionButtonColumn(label: string, type: string, requestId: string,
|
|
|
179
310
|
}
|
|
180
311
|
}
|
|
181
312
|
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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,
|
|
186
341
|
): object {
|
|
187
|
-
const
|
|
188
|
-
const
|
|
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 })
|
|
189
390
|
return {
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
header: {
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
},
|
|
196
|
-
body: {
|
|
197
|
-
elements: [{
|
|
198
|
-
tag: 'markdown',
|
|
199
|
-
content: `${ok ? '✅' : '❌'} **${label}** by ${user || '匿名'} · ${new Date().toLocaleTimeString('zh-CN', { hour12: false })}`,
|
|
200
|
-
}],
|
|
201
|
-
},
|
|
391
|
+
tag: 'collapsible_panel',
|
|
392
|
+
element_id: ELEMENTS.tool(i),
|
|
393
|
+
header: { title: { tag: 'plain_text', content: headerText } },
|
|
394
|
+
expanded: true,
|
|
395
|
+
elements: bodyElements,
|
|
202
396
|
}
|
|
203
397
|
}
|
|
204
398
|
|
|
@@ -207,36 +401,92 @@ interface ConsoleOpts {
|
|
|
207
401
|
status: 'idle' | 'working' | 'awaiting_permission' | 'starting' | 'stopped'
|
|
208
402
|
model?: string
|
|
209
403
|
effort?: string
|
|
210
|
-
|
|
211
|
-
|
|
404
|
+
/** ms since this ClaudeProcess spawned — formatted to "1h 23m" inside. */
|
|
405
|
+
uptimeMs?: number
|
|
406
|
+
/** Current context-window occupancy estimate (input + cache tokens of
|
|
407
|
+
* the last assistant message). 0 if no turn has completed yet. */
|
|
408
|
+
contextTokens?: number
|
|
409
|
+
/** Window upper bound. Defaults to 1M (claude-opus-4-7[1m]). */
|
|
410
|
+
contextLimit?: number
|
|
411
|
+
cumStats?: { tokens: number; costUsd: number; turns: number }
|
|
412
|
+
lastTurn?: { tokens: number; costUsd: number; durationMs: number }
|
|
413
|
+
sessionId?: string | null
|
|
212
414
|
hasSession: boolean
|
|
213
415
|
}
|
|
214
416
|
|
|
417
|
+
/** Format token counts as a compact human-readable string: 1,234 → 1.2K. */
|
|
418
|
+
function fmtTokens(n: number): string {
|
|
419
|
+
if (!n) return '0'
|
|
420
|
+
if (n < 1000) return String(n)
|
|
421
|
+
if (n < 1_000_000) return (n / 1000).toFixed(n < 10_000 ? 1 : 0).replace(/\.0$/, '') + 'K'
|
|
422
|
+
return (n / 1_000_000).toFixed(2).replace(/\.?0+$/, '') + 'M'
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function fmtCost(c: number): string {
|
|
426
|
+
if (c < 0.01) return `$${c.toFixed(4)}`
|
|
427
|
+
return `$${c.toFixed(2)}`
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function fmtDurationMs(ms: number): string {
|
|
431
|
+
if (ms < 1000) return `${ms}ms`
|
|
432
|
+
if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`
|
|
433
|
+
const m = Math.floor(ms / 60_000)
|
|
434
|
+
const s = Math.round((ms % 60_000) / 1000)
|
|
435
|
+
return `${m}m ${s}s`
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
function fmtUptime(ms: number): string {
|
|
439
|
+
if (ms < 60_000) return `${Math.round(ms / 1000)}s`
|
|
440
|
+
const totalMin = Math.floor(ms / 60_000)
|
|
441
|
+
if (totalMin < 60) return `${totalMin}m`
|
|
442
|
+
const h = Math.floor(totalMin / 60)
|
|
443
|
+
const m = totalMin % 60
|
|
444
|
+
if (h < 24) return `${h}h ${m}m`
|
|
445
|
+
const d = Math.floor(h / 24)
|
|
446
|
+
return `${d}d ${h % 24}h`
|
|
447
|
+
}
|
|
448
|
+
|
|
215
449
|
export function consoleCard(opts: ConsoleOpts): object {
|
|
216
|
-
const {
|
|
450
|
+
const {
|
|
451
|
+
sessionName, status, model, effort, uptimeMs,
|
|
452
|
+
contextTokens, contextLimit, cumStats, lastTurn, sessionId, hasSession,
|
|
453
|
+
} = opts
|
|
217
454
|
const statusEmoji = {
|
|
218
455
|
idle: '🟢 闲', working: '⚙️ 工作中', awaiting_permission: '🔐 等审批',
|
|
219
456
|
starting: '🚀 启动中', stopped: '⚪ 未运行',
|
|
220
457
|
}[status]
|
|
221
|
-
const meta = [
|
|
222
|
-
`状态: ${statusEmoji}`,
|
|
223
|
-
model ? `模型: ${model}${effort ? `/${effort}` : ''}` : null,
|
|
224
|
-
uptime ? `运行: ${uptime}` : null,
|
|
225
|
-
lastActivity ? `最近: ${lastActivity}` : null,
|
|
226
|
-
].filter(Boolean).join(' · ')
|
|
227
458
|
|
|
228
|
-
const
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
459
|
+
const modelLine = model ? `${model}${effort ? `/${effort}` : ''}` : null
|
|
460
|
+
const headerLine = [statusEmoji, modelLine].filter(Boolean).join(' · ')
|
|
461
|
+
|
|
462
|
+
// Build the metric lines that make this panel useful. Each is "label
|
|
463
|
+
// <tab> value" rendered as plain markdown — keeps it readable inside
|
|
464
|
+
// the small Feishu card area without competing with the button row.
|
|
465
|
+
const lines: string[] = [headerLine]
|
|
466
|
+
|
|
467
|
+
if (contextTokens != null) {
|
|
468
|
+
const limit = contextLimit ?? 1_000_000
|
|
469
|
+
const pct = limit > 0 ? Math.round((contextTokens / limit) * 100) : 0
|
|
470
|
+
lines.push(`**📦 上下文** ${fmtTokens(contextTokens)} / ${fmtTokens(limit)} (${pct}%)`)
|
|
471
|
+
}
|
|
472
|
+
if (uptimeMs != null && uptimeMs > 0) {
|
|
473
|
+
lines.push(`**⏱ 已运行** ${fmtUptime(uptimeMs)}`)
|
|
474
|
+
}
|
|
475
|
+
if (cumStats && (cumStats.tokens > 0 || cumStats.costUsd > 0 || cumStats.turns > 0)) {
|
|
476
|
+
lines.push(`**💬 累计** ${fmtTokens(cumStats.tokens)} tokens · ${fmtCost(cumStats.costUsd)} · ${cumStats.turns} turn${cumStats.turns === 1 ? '' : 's'}`)
|
|
477
|
+
}
|
|
478
|
+
if (lastTurn) {
|
|
479
|
+
lines.push(`**🔄 上一轮** +${fmtTokens(lastTurn.tokens)} · ${fmtCost(lastTurn.costUsd)} · ${fmtDurationMs(lastTurn.durationMs)}`)
|
|
480
|
+
}
|
|
481
|
+
if (sessionId) {
|
|
482
|
+
lines.push(`**🆔 session** \`${sessionId.slice(0, 8)}…\``)
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
void hasSession // accept the field for caller compat; lifecycle is now
|
|
486
|
+
// driven by bare-word commands (`hi` / `kill` / `restart` / `clear`),
|
|
487
|
+
// not buttons — keeps the panel pure-readout and one-handed mobile-
|
|
488
|
+
// friendly. The 'refresh' / 'ls' actions stay in onConsoleAction for
|
|
489
|
+
// backward compat with any still-floating older cards in chat history.
|
|
240
490
|
|
|
241
491
|
const template = status === 'working' ? 'blue'
|
|
242
492
|
: status === 'awaiting_permission' ? 'orange'
|
|
@@ -252,19 +502,7 @@ export function consoleCard(opts: ConsoleOpts): object {
|
|
|
252
502
|
},
|
|
253
503
|
body: {
|
|
254
504
|
elements: [
|
|
255
|
-
{ tag: 'markdown', content:
|
|
256
|
-
{
|
|
257
|
-
tag: 'column_set',
|
|
258
|
-
columns: buttons.map(([label, action, kind]) => ({
|
|
259
|
-
tag: 'column', width: 'weighted', weight: 1,
|
|
260
|
-
elements: [{
|
|
261
|
-
tag: 'button',
|
|
262
|
-
text: { tag: 'plain_text', content: label },
|
|
263
|
-
type: kind,
|
|
264
|
-
behaviors: [{ type: 'callback', value: { kind: 'console', action } }],
|
|
265
|
-
}],
|
|
266
|
-
})),
|
|
267
|
-
},
|
|
505
|
+
{ tag: 'markdown', content: lines.join('\n\n') },
|
|
268
506
|
],
|
|
269
507
|
},
|
|
270
508
|
}
|
package/src/claude-process.ts
CHANGED
|
@@ -44,6 +44,23 @@ export interface CanUseToolRequest {
|
|
|
44
44
|
tool_use_id?: string
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
+
/** Subset of the SDK's `usage` object — only the fields the console
|
|
48
|
+
* panel actually shows. Anthropic adds new ones (server_tool_use,
|
|
49
|
+
* service_tier, iterations, …) without breaking existing readers. */
|
|
50
|
+
export interface ClaudeUsage {
|
|
51
|
+
input_tokens?: number
|
|
52
|
+
output_tokens?: number
|
|
53
|
+
cache_creation_input_tokens?: number
|
|
54
|
+
cache_read_input_tokens?: number
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface ClaudeResultMeta {
|
|
58
|
+
cost_usd: number | null
|
|
59
|
+
duration_ms: number | null
|
|
60
|
+
num_turns: number | null
|
|
61
|
+
usage: ClaudeUsage | null
|
|
62
|
+
}
|
|
63
|
+
|
|
47
64
|
export interface HookCallbackRequest {
|
|
48
65
|
request_id: string
|
|
49
66
|
callback_id: string
|
|
@@ -60,6 +77,22 @@ export class ClaudeProcess extends EventEmitter {
|
|
|
60
77
|
private expectedExit = false
|
|
61
78
|
sessionId: string | null = null
|
|
62
79
|
lastAssistantUuid: string | null = null
|
|
80
|
+
/** Model id (`claude-opus-4-7`, …) from the most recent assistant
|
|
81
|
+
* message — surfaced in the console panel so the user sees what's
|
|
82
|
+
* actually running, not whatever default we'd assume. */
|
|
83
|
+
lastModel: string | null = null
|
|
84
|
+
/** Usage from the most recent `assistant` message — input_tokens +
|
|
85
|
+
* cache_* on the *latest* message is the best proxy for current
|
|
86
|
+
* context-window occupancy, since each assistant turn replays the
|
|
87
|
+
* accumulated conversation. */
|
|
88
|
+
lastUsage: ClaudeUsage | null = null
|
|
89
|
+
/** Result-level metadata from the most recent `result` message —
|
|
90
|
+
* per-turn cost / wallclock / num_turns. Cleared to null only on
|
|
91
|
+
* spawn; persists across turns so the console panel can show "last
|
|
92
|
+
* turn" stats even while idle. */
|
|
93
|
+
lastResult: ClaudeResultMeta = {
|
|
94
|
+
cost_usd: null, duration_ms: null, num_turns: null, usage: null,
|
|
95
|
+
}
|
|
63
96
|
|
|
64
97
|
constructor(opts: SpawnOpts) {
|
|
65
98
|
super()
|
|
@@ -184,6 +217,8 @@ export class ClaudeProcess extends EventEmitter {
|
|
|
184
217
|
this.emit('thinking', { uuid: msg.uuid, text: block.thinking })
|
|
185
218
|
}
|
|
186
219
|
}
|
|
220
|
+
if (msg.message?.usage) this.lastUsage = msg.message.usage as ClaudeUsage
|
|
221
|
+
if (typeof msg.message?.model === 'string') this.lastModel = msg.message.model
|
|
187
222
|
if (msg.uuid) this.lastAssistantUuid = msg.uuid
|
|
188
223
|
return
|
|
189
224
|
}
|
|
@@ -201,6 +236,12 @@ export class ClaudeProcess extends EventEmitter {
|
|
|
201
236
|
return
|
|
202
237
|
}
|
|
203
238
|
if (type === 'result') {
|
|
239
|
+
this.lastResult = {
|
|
240
|
+
cost_usd: typeof msg.total_cost_usd === 'number' ? msg.total_cost_usd : null,
|
|
241
|
+
duration_ms: typeof msg.duration_ms === 'number' ? msg.duration_ms : null,
|
|
242
|
+
num_turns: typeof msg.num_turns === 'number' ? msg.num_turns : null,
|
|
243
|
+
usage: msg.usage ?? null,
|
|
244
|
+
}
|
|
204
245
|
this.emit('result', msg)
|
|
205
246
|
return
|
|
206
247
|
}
|
|
@@ -245,19 +286,53 @@ export class ClaudeProcess extends EventEmitter {
|
|
|
245
286
|
})
|
|
246
287
|
}
|
|
247
288
|
|
|
248
|
-
|
|
289
|
+
/** Response schema for `can_use_tool` control_request (NOT the
|
|
290
|
+
* PreToolUse-hook output shape that lives elsewhere in the SDK).
|
|
291
|
+
* allow → { behavior: 'allow', updatedInput: <record> }
|
|
292
|
+
* deny → { behavior: 'deny', message: <string> }
|
|
293
|
+
* Sending the wrong shape gets Zod-rejected on the Claude side and
|
|
294
|
+
* the decision never lands — the card visually flips to "resolved"
|
|
295
|
+
* but Claude immediately re-fires the same can_use_tool, looking to
|
|
296
|
+
* the user like a "flash back to original" bug. */
|
|
297
|
+
sendPermissionResponse(
|
|
298
|
+
requestId: string,
|
|
299
|
+
decision: 'allow' | 'deny',
|
|
300
|
+
payload?: { updatedInput?: Record<string, unknown>; denyMessage?: string },
|
|
301
|
+
): void {
|
|
302
|
+
const response = decision === 'allow'
|
|
303
|
+
? { behavior: 'allow' as const, updatedInput: payload?.updatedInput ?? {} }
|
|
304
|
+
: { behavior: 'deny' as const, message: payload?.denyMessage ?? 'denied by user' }
|
|
249
305
|
this.write({
|
|
250
306
|
type: 'control_response',
|
|
251
307
|
response: {
|
|
252
308
|
subtype: 'success',
|
|
253
309
|
request_id: requestId,
|
|
254
|
-
response
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
310
|
+
response,
|
|
311
|
+
},
|
|
312
|
+
})
|
|
313
|
+
}
|
|
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
|
+
}],
|
|
261
336
|
},
|
|
262
337
|
})
|
|
263
338
|
}
|
package/src/feishu.ts
CHANGED
|
@@ -12,7 +12,7 @@ import { mkdirSync, readFileSync, realpathSync, statSync, writeFileSync } from '
|
|
|
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 } from './paths'
|
|
15
|
+
import { 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
|
|
@@ -70,6 +70,42 @@ export function bindSessionToChat(sessionName: string, chatId: string): void {
|
|
|
70
70
|
log(`feishu: bound session "${sessionName}" → ${chatId}${prev ? ` (was ${prev})` : ''}`)
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
+
// ── Session resume map ────────────────────────────────────────────────
|
|
74
|
+
// `sessionName → last-known claude session_id`. Persisted so a daemon
|
|
75
|
+
// restart (systemctl, crash, watchdog) doesn't strand the user with a
|
|
76
|
+
// fresh conversation when they next type `restart`. Updated on every
|
|
77
|
+
// `system/init` from a claude subprocess.
|
|
78
|
+
const lastSessionIdByName = new Map<string, string>()
|
|
79
|
+
|
|
80
|
+
export function loadSessionResumeMap(): void {
|
|
81
|
+
try {
|
|
82
|
+
const obj = JSON.parse(readFileSync(SESSION_RESUME_MAP_FILE, 'utf8'))
|
|
83
|
+
for (const [name, id] of Object.entries(obj)) {
|
|
84
|
+
if (typeof id === 'string') lastSessionIdByName.set(name, id)
|
|
85
|
+
}
|
|
86
|
+
log(`feishu: loaded ${lastSessionIdByName.size} session→resume bindings`)
|
|
87
|
+
} catch {}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function saveSessionResumeMap(): void {
|
|
91
|
+
try {
|
|
92
|
+
const obj: Record<string, string> = {}
|
|
93
|
+
for (const [k, v] of lastSessionIdByName) obj[k] = v
|
|
94
|
+
mkdirSync(DATA_DIR, { recursive: true })
|
|
95
|
+
writeFileSync(SESSION_RESUME_MAP_FILE, JSON.stringify(obj, null, 2))
|
|
96
|
+
} catch (e) { log(`feishu: save session-resume-map failed: ${e}`) }
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function bindSessionResume(sessionName: string, sessionId: string): void {
|
|
100
|
+
if (lastSessionIdByName.get(sessionName) === sessionId) return
|
|
101
|
+
lastSessionIdByName.set(sessionName, sessionId)
|
|
102
|
+
saveSessionResumeMap()
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function getSessionResume(sessionName: string): string | null {
|
|
106
|
+
return lastSessionIdByName.get(sessionName) ?? null
|
|
107
|
+
}
|
|
108
|
+
|
|
73
109
|
export function chatIdForSession(sessionName: string): string | null {
|
|
74
110
|
const preferred = preferredChatForSession.get(sessionName)
|
|
75
111
|
if (preferred && chatNameCache.get(preferred) === sessionName) return preferred
|
|
@@ -127,23 +163,6 @@ export async function sendCard(chatId: string, card: object): Promise<string | n
|
|
|
127
163
|
} catch (e) { log(`feishu: sendCard failed chat=${chatId}: ${e}`); return null }
|
|
128
164
|
}
|
|
129
165
|
|
|
130
|
-
// PATCH a regular interactive message (i.e. a card NOT promoted to a
|
|
131
|
-
// cardkit entity). Used for permission cards that flip allow/deny once.
|
|
132
|
-
export async function patchCardMessage(messageId: string, card: object): Promise<void> {
|
|
133
|
-
try {
|
|
134
|
-
const token = await getTenantToken()
|
|
135
|
-
const res = await fetch(`https://open.feishu.cn/open-apis/im/v1/messages/${messageId}`, {
|
|
136
|
-
method: 'PATCH',
|
|
137
|
-
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
|
138
|
-
body: JSON.stringify({ content: JSON.stringify(card) }),
|
|
139
|
-
})
|
|
140
|
-
const data = await res.json() as any
|
|
141
|
-
if (data?.code && data.code !== 0) {
|
|
142
|
-
log(`feishu: patchCardMessage ${messageId} code=${data.code} msg=${data.msg}`)
|
|
143
|
-
}
|
|
144
|
-
} catch (e) { log(`feishu: patchCardMessage ${messageId} failed: ${e}`) }
|
|
145
|
-
}
|
|
146
|
-
|
|
147
166
|
// ── Reactions ──────────────────────────────────────────────────────────
|
|
148
167
|
export async function addReaction(messageId: string, emojiType: string): Promise<void> {
|
|
149
168
|
if (!messageId) return
|
package/src/paths.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* daemon.pid — single-instance lock (in DATA_DIR)
|
|
9
9
|
* daemon.log — append-only run log (in DATA_DIR)
|
|
10
10
|
* session-chat-map.json — duplicate-name routing (in DATA_DIR)
|
|
11
|
+
* session-resume-map.json — last-known claude session_id (in DATA_DIR)
|
|
11
12
|
* inbox/ — downloaded attachments (in DATA_DIR)
|
|
12
13
|
*/
|
|
13
14
|
|
|
@@ -38,4 +39,5 @@ export const CONFIG_FILE = process.env.LODESTAR_CONFIG ?? join(CONFIG_DIR, 'conf
|
|
|
38
39
|
export const PID_FILE = join(DATA_DIR, 'daemon.pid')
|
|
39
40
|
export const LOG_FILE = join(DATA_DIR, 'daemon.log')
|
|
40
41
|
export const SESSION_CHAT_MAP_FILE = join(DATA_DIR, 'session-chat-map.json')
|
|
42
|
+
export const SESSION_RESUME_MAP_FILE = join(DATA_DIR, 'session-resume-map.json')
|
|
41
43
|
export const INBOX_DIR = join(DATA_DIR, 'inbox')
|
package/src/session.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
import { existsSync } from 'node:fs'
|
|
11
11
|
import { join } from 'node:path'
|
|
12
|
-
import { ClaudeProcess, type CanUseToolRequest, type HookCallbackRequest } from './claude-process'
|
|
12
|
+
import { ClaudeProcess, type CanUseToolRequest, type HookCallbackRequest, type ClaudeUsage } from './claude-process'
|
|
13
13
|
import { CHANNEL_INSTRUCTIONS } from './instructions'
|
|
14
14
|
import * as cardkit from './cardkit'
|
|
15
15
|
import * as cards from './cards'
|
|
@@ -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
|
|
@@ -41,23 +52,65 @@ export interface SessionOpts {
|
|
|
41
52
|
permissionMode?: 'default' | 'acceptEdits' | 'plan' | 'bypassPermissions'
|
|
42
53
|
}
|
|
43
54
|
|
|
55
|
+
/** Per-turn delta extracted from the SDK `result` message — feeds the
|
|
56
|
+
* "上一轮" line in the console panel. */
|
|
57
|
+
interface LastTurnDelta {
|
|
58
|
+
tokens: number // input + cache_* + output for that turn
|
|
59
|
+
costUsd: number
|
|
60
|
+
durationMs: number
|
|
61
|
+
inputTokens: number // input + cache_* (excludes output) — context-window estimate
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Cumulative session counters. Reset on full restart (`clear`), preserved
|
|
65
|
+
* across resume — but resumed conversations start counting from the
|
|
66
|
+
* resume point, not the original turn 0; the SDK doesn't replay historical
|
|
67
|
+
* usage. The session_id continuity is preserved separately by the resume
|
|
68
|
+
* map; cumStats represents "since the current ClaudeProcess was spawned". */
|
|
69
|
+
interface CumStats {
|
|
70
|
+
tokens: number
|
|
71
|
+
costUsd: number
|
|
72
|
+
turns: number
|
|
73
|
+
}
|
|
74
|
+
|
|
44
75
|
export class Session {
|
|
45
76
|
private proc: ClaudeProcess | null = null
|
|
46
77
|
private currentTurn: TurnState | null = null
|
|
47
|
-
private pendingPermissions = new Map<string, {
|
|
78
|
+
private pendingPermissions = new Map<string, { toolUseId: string }>()
|
|
79
|
+
/** Open AskUserQuestion tool calls — keyed by tool_use_id, valued
|
|
80
|
+
* with the questions array so we can construct the `answers` reply
|
|
81
|
+
* once a button is clicked. Cleared when the answer is sent or the
|
|
82
|
+
* turn ends. */
|
|
83
|
+
private pendingAsks = new Map<string, { questions: cards.AskQuestion[]; i: number }>()
|
|
48
84
|
private turnCounter = 0
|
|
49
85
|
// Last seen sessionId — preserved across `kill`/`stop` so a later
|
|
50
86
|
// `restart` can resume the same Claude conversation even after the
|
|
51
87
|
// child process is gone.
|
|
52
88
|
private lastSessionId: string | null = null
|
|
53
89
|
private startedAt: number = 0
|
|
90
|
+
private cumStats: CumStats = { tokens: 0, costUsd: 0, turns: 0 }
|
|
91
|
+
private lastTurnDelta: LastTurnDelta | null = null
|
|
92
|
+
/** Local mirror of the SDK's task list — built incrementally from
|
|
93
|
+
* TaskCreate / TaskUpdate input+output pairs and rendered as a footer
|
|
94
|
+
* on every Task* panel. Lives for the lifetime of the Session
|
|
95
|
+
* instance; daemon restart wipes it (the SDK doesn't replay history).
|
|
96
|
+
* Not authoritative — Claude calling TaskList is still the source of
|
|
97
|
+
* truth; this mirror is purely for the panel readout. */
|
|
98
|
+
private currentTodos = new Map<number, cards.Todo>()
|
|
54
99
|
status: Status = 'stopped'
|
|
55
100
|
|
|
56
101
|
constructor(
|
|
57
102
|
public readonly sessionName: string,
|
|
58
103
|
public readonly chatId: string,
|
|
59
104
|
private opts: SessionOpts = {},
|
|
60
|
-
) {
|
|
105
|
+
) {
|
|
106
|
+
// Restore last-known claude session_id from disk so a daemon restart
|
|
107
|
+
// (systemctl, crash, watchdog) doesn't strand the user with a fresh
|
|
108
|
+
// conversation when they next type `restart`.
|
|
109
|
+
this.lastSessionId = feishu.getSessionResume(sessionName)
|
|
110
|
+
if (this.lastSessionId) {
|
|
111
|
+
log(`session "${sessionName}": restored lastSessionId=${this.lastSessionId.slice(0, 8)}…`)
|
|
112
|
+
}
|
|
113
|
+
}
|
|
61
114
|
|
|
62
115
|
get workDir(): string { return join(feishu.PROJECTS_ROOT, this.sessionName) }
|
|
63
116
|
isRunning(): boolean { return !!this.proc && this.proc.isAlive() }
|
|
@@ -82,7 +135,7 @@ export class Session {
|
|
|
82
135
|
this.proc = new ClaudeProcess({
|
|
83
136
|
workDir: this.workDir,
|
|
84
137
|
effort: 'max',
|
|
85
|
-
permissionMode: this.opts.permissionMode ?? '
|
|
138
|
+
permissionMode: this.opts.permissionMode ?? 'bypassPermissions',
|
|
86
139
|
appendSystemPrompt: CHANNEL_INSTRUCTIONS,
|
|
87
140
|
})
|
|
88
141
|
this.wireProc(this.proc)
|
|
@@ -122,7 +175,7 @@ export class Session {
|
|
|
122
175
|
this.proc = new ClaudeProcess({
|
|
123
176
|
workDir: this.workDir,
|
|
124
177
|
effort: 'max',
|
|
125
|
-
permissionMode: '
|
|
178
|
+
permissionMode: this.opts.permissionMode ?? 'bypassPermissions',
|
|
126
179
|
resumeSessionId: prevSessionId,
|
|
127
180
|
appendSystemPrompt: CHANNEL_INSTRUCTIONS,
|
|
128
181
|
})
|
|
@@ -132,6 +185,16 @@ export class Session {
|
|
|
132
185
|
this.startedAt = Date.now()
|
|
133
186
|
await feishu.sendText(this.chatId, `🔁 已重启并恢复 session=${prevSessionId.slice(0, 8)}…`)
|
|
134
187
|
} else {
|
|
188
|
+
// Resume requested but no prior session_id on file — surface it
|
|
189
|
+
// explicitly rather than silently fresh-starting (the old behavior
|
|
190
|
+
// hid the daemon-restart sessionId-loss bug for months).
|
|
191
|
+
if (resume) {
|
|
192
|
+
await feishu.sendText(this.chatId, '⚠️ 没有可恢复的上一会话,将以新会话启动')
|
|
193
|
+
}
|
|
194
|
+
// Fresh conversation — drop cumulative stats so the next `hi` shows
|
|
195
|
+
// zeroed counters instead of bleeding numbers from the prior chat.
|
|
196
|
+
this.cumStats = { tokens: 0, costUsd: 0, turns: 0 }
|
|
197
|
+
this.lastTurnDelta = null
|
|
135
198
|
await this.start()
|
|
136
199
|
}
|
|
137
200
|
}
|
|
@@ -168,14 +231,27 @@ export class Session {
|
|
|
168
231
|
}
|
|
169
232
|
|
|
170
233
|
async showConsole(): Promise<void> {
|
|
171
|
-
const
|
|
172
|
-
|
|
173
|
-
|
|
234
|
+
const uptimeMs = this.startedAt ? (Date.now() - this.startedAt) : undefined
|
|
235
|
+
// Strip the `claude-` prefix so the panel stays compact: `opus-4-7`
|
|
236
|
+
// reads better than `claude-opus-4-7` in the small status header.
|
|
237
|
+
const rawModel = this.proc?.lastModel ?? null
|
|
238
|
+
const model = rawModel ? rawModel.replace(/^claude-/, '') : undefined
|
|
174
239
|
const card = cards.consoleCard({
|
|
175
240
|
sessionName: this.sessionName,
|
|
176
241
|
status: this.status,
|
|
242
|
+
model,
|
|
177
243
|
effort: 'max',
|
|
178
|
-
|
|
244
|
+
uptimeMs,
|
|
245
|
+
contextTokens: this.currentContextTokens(),
|
|
246
|
+
cumStats: this.cumStats,
|
|
247
|
+
lastTurn: this.lastTurnDelta
|
|
248
|
+
? {
|
|
249
|
+
tokens: this.lastTurnDelta.tokens,
|
|
250
|
+
costUsd: this.lastTurnDelta.costUsd,
|
|
251
|
+
durationMs: this.lastTurnDelta.durationMs,
|
|
252
|
+
}
|
|
253
|
+
: undefined,
|
|
254
|
+
sessionId: this.proc?.sessionId ?? this.lastSessionId,
|
|
179
255
|
hasSession: this.isRunning(),
|
|
180
256
|
})
|
|
181
257
|
await feishu.sendCard(this.chatId, card)
|
|
@@ -212,8 +288,22 @@ export class Session {
|
|
|
212
288
|
if (!pending) { log(`session "${this.sessionName}": stray permission ${requestId}`); return }
|
|
213
289
|
this.pendingPermissions.delete(requestId)
|
|
214
290
|
|
|
215
|
-
|
|
216
|
-
|
|
291
|
+
// Update the tool element in the main turn card in place — the
|
|
292
|
+
// permission decision lives on the same row as the tool call.
|
|
293
|
+
const turn = this.currentTurn
|
|
294
|
+
const meta = turn?.toolByUseId.get(pending.toolUseId)
|
|
295
|
+
if (turn && meta) {
|
|
296
|
+
const todos = this.isTaskWorkflow(meta.name) ? this.todosArray() : undefined
|
|
297
|
+
if (decision === 'deny') {
|
|
298
|
+
const el = cards.toolCallElement(meta.i, meta.name, meta.input, `🚫 已拒绝 by ${user || '匿名'}`, '❌', undefined, todos)
|
|
299
|
+
void cardkit.replaceElement(turn.cardId, cards.ELEMENTS.tool(meta.i), el)
|
|
300
|
+
} else {
|
|
301
|
+
const label = decision === 'allow_always' ? '始终允许' : '已允许'
|
|
302
|
+
meta.resolvedNote = `✅ **${label}** by ${user || '匿名'}`
|
|
303
|
+
const el = cards.toolCallElement(meta.i, meta.name, meta.input, null, '⏳', meta.resolvedNote, todos)
|
|
304
|
+
void cardkit.replaceElement(turn.cardId, cards.ELEMENTS.tool(meta.i), el)
|
|
305
|
+
}
|
|
306
|
+
}
|
|
217
307
|
|
|
218
308
|
const claudeDecision = decision === 'deny' ? 'deny' : 'allow'
|
|
219
309
|
this.proc?.sendPermissionResponse(requestId, claudeDecision)
|
|
@@ -227,6 +317,42 @@ export class Session {
|
|
|
227
317
|
}
|
|
228
318
|
}
|
|
229
319
|
|
|
320
|
+
/** Handle a click on an AskUserQuestion option button. We construct
|
|
321
|
+
* the SDK-shaped `answers` map (question text → picked option label),
|
|
322
|
+
* send it back as a `tool_result`, and freeze the panel showing the
|
|
323
|
+
* picked option. The permission half of the flow was already auto-
|
|
324
|
+
* allowed in `renderPermission`, so no second action needed there. */
|
|
325
|
+
async onAskAnswer(toolUseId: string, questionIdx: number, optionIdx: number, user: string): Promise<void> {
|
|
326
|
+
const pending = this.pendingAsks.get(toolUseId)
|
|
327
|
+
if (!pending) { log(`session "${this.sessionName}": stray ask answer for ${toolUseId}`); return }
|
|
328
|
+
const q = pending.questions[questionIdx]
|
|
329
|
+
const opt = q?.options?.[optionIdx]
|
|
330
|
+
if (!q || !opt) {
|
|
331
|
+
log(`session "${this.sessionName}": ask answer out of range q=${questionIdx} o=${optionIdx}`)
|
|
332
|
+
return
|
|
333
|
+
}
|
|
334
|
+
// Build the answers map. SDK keys answers by the question text
|
|
335
|
+
// (matches what the AskUserQuestion contract expects). For
|
|
336
|
+
// unanswered secondary questions we don't include them — the SDK
|
|
337
|
+
// tolerates a partial map; Claude will see whichever it got.
|
|
338
|
+
const answers: Record<string, string> = { [q.question]: opt.label }
|
|
339
|
+
const content = JSON.stringify({ answers })
|
|
340
|
+
this.proc?.sendToolResult(toolUseId, content)
|
|
341
|
+
this.pendingAsks.delete(toolUseId)
|
|
342
|
+
|
|
343
|
+
// Repaint the panel to reflect the final choice — same element_id,
|
|
344
|
+
// status flips to ✅, body shows what was picked.
|
|
345
|
+
const turn = this.currentTurn
|
|
346
|
+
const meta = turn?.toolByUseId.get(toolUseId)
|
|
347
|
+
if (turn && meta) {
|
|
348
|
+
meta.output = content
|
|
349
|
+
meta.isError = false
|
|
350
|
+
const resolvedNote = `\n\n✅ **已回答** by ${user || '匿名'}: ${opt.label}`
|
|
351
|
+
const el = cards.askUserQuestionElement(meta.i, toolUseId, pending.questions, '✅', resolvedNote)
|
|
352
|
+
void cardkit.replaceElement(turn.cardId, cards.ELEMENTS.tool(meta.i), el)
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
230
356
|
async onConsoleAction(action: string): Promise<void> {
|
|
231
357
|
log(`session "${this.sessionName}": console action=${action}`)
|
|
232
358
|
switch (action) {
|
|
@@ -235,12 +361,22 @@ export class Session {
|
|
|
235
361
|
case 'stop': await this.stop(); break
|
|
236
362
|
case 'start': await this.start(); break
|
|
237
363
|
case 'resume': await this.restart(true); break
|
|
364
|
+
case 'refresh': await this.showConsole(); break
|
|
238
365
|
case 'ls': await feishu.sendText(this.chatId, `📁 ${this.workDir}`); break
|
|
239
366
|
}
|
|
240
367
|
}
|
|
241
368
|
|
|
242
369
|
// ── Wiring Claude → Feishu ─────────────────────────────────────────
|
|
243
370
|
private wireProc(p: ClaudeProcess): void {
|
|
371
|
+
p.on('init', () => {
|
|
372
|
+
// Persist the freshly assigned session_id so a later daemon
|
|
373
|
+
// restart can resume this conversation. Skip if unchanged to
|
|
374
|
+
// avoid hammering the file on every init for resumed sessions.
|
|
375
|
+
if (p.sessionId && p.sessionId !== this.lastSessionId) {
|
|
376
|
+
this.lastSessionId = p.sessionId
|
|
377
|
+
feishu.bindSessionResume(this.sessionName, p.sessionId)
|
|
378
|
+
}
|
|
379
|
+
})
|
|
244
380
|
p.on('assistant_text', ({ text }: { text: string }) => {
|
|
245
381
|
this.appendAssistant(text)
|
|
246
382
|
})
|
|
@@ -254,13 +390,14 @@ export class Session {
|
|
|
254
390
|
this.completeTool(tool_use_id, content, is_error)
|
|
255
391
|
})
|
|
256
392
|
p.on('can_use_tool', (req: CanUseToolRequest) => {
|
|
257
|
-
|
|
393
|
+
this.renderPermission(req)
|
|
258
394
|
})
|
|
259
395
|
p.on('hook_callback', (req: HookCallbackRequest) => {
|
|
260
396
|
// No hooks registered → fail-safe ack.
|
|
261
397
|
this.proc?.sendHookResponse(req.request_id, {})
|
|
262
398
|
})
|
|
263
399
|
p.on('result', () => {
|
|
400
|
+
this.accumulateResultStats()
|
|
264
401
|
void this.closeTurnCard()
|
|
265
402
|
this.status = 'idle'
|
|
266
403
|
})
|
|
@@ -275,6 +412,37 @@ export class Session {
|
|
|
275
412
|
})
|
|
276
413
|
}
|
|
277
414
|
|
|
415
|
+
/** Pull per-turn numbers off `proc.lastResult` (set by ClaudeProcess when
|
|
416
|
+
* the `result` message landed) and roll them into cumStats + the
|
|
417
|
+
* "上一轮" delta. Called exactly once per result event, right before
|
|
418
|
+
* closeTurnCard. */
|
|
419
|
+
private accumulateResultStats(): void {
|
|
420
|
+
const r = this.proc?.lastResult
|
|
421
|
+
if (!r) return
|
|
422
|
+
const u = r.usage ?? {}
|
|
423
|
+
const inputTokens = (u.input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0) + (u.cache_read_input_tokens ?? 0)
|
|
424
|
+
const outputTokens = u.output_tokens ?? 0
|
|
425
|
+
const tokens = inputTokens + outputTokens
|
|
426
|
+
const costUsd = r.cost_usd ?? 0
|
|
427
|
+
const durationMs = r.duration_ms ?? 0
|
|
428
|
+
this.cumStats.tokens += tokens
|
|
429
|
+
this.cumStats.costUsd += costUsd
|
|
430
|
+
this.cumStats.turns += r.num_turns ?? 1
|
|
431
|
+
this.lastTurnDelta = { tokens, costUsd, durationMs, inputTokens }
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/** Current context-window occupancy estimate — uses the most recent
|
|
435
|
+
* assistant `usage` (input + caches), since each assistant reply replays
|
|
436
|
+
* the full conversation. Falls back to the last-turn delta when no
|
|
437
|
+
* assistant message has streamed yet this process. */
|
|
438
|
+
private currentContextTokens(): number {
|
|
439
|
+
const u = this.proc?.lastUsage as ClaudeUsage | null | undefined
|
|
440
|
+
if (u) {
|
|
441
|
+
return (u.input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0) + (u.cache_read_input_tokens ?? 0)
|
|
442
|
+
}
|
|
443
|
+
return this.lastTurnDelta?.inputTokens ?? 0
|
|
444
|
+
}
|
|
445
|
+
|
|
278
446
|
private async openTurnCard(userText: string): Promise<void> {
|
|
279
447
|
const turn = ++this.turnCounter
|
|
280
448
|
const card = cards.mainConversationCard({
|
|
@@ -338,6 +506,14 @@ export class Session {
|
|
|
338
506
|
)
|
|
339
507
|
}
|
|
340
508
|
|
|
509
|
+
private isTaskWorkflow(name: string): boolean {
|
|
510
|
+
return name.startsWith('Task') && name !== 'Task'
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
private todosArray(): cards.Todo[] {
|
|
514
|
+
return [...this.currentTodos.values()]
|
|
515
|
+
}
|
|
516
|
+
|
|
341
517
|
private addTool(toolUseId: string, name: string, input: any): void {
|
|
342
518
|
if (!this.currentTurn) return
|
|
343
519
|
// Close current assistant segment (if any) so the tool panel renders
|
|
@@ -350,7 +526,27 @@ export class Session {
|
|
|
350
526
|
}
|
|
351
527
|
const i = this.currentTurn.toolCount++
|
|
352
528
|
this.currentTurn.toolByUseId.set(toolUseId, { i, name, input })
|
|
353
|
-
|
|
529
|
+
// AskUserQuestion is a client-side tool — daemon renders the choice
|
|
530
|
+
// UI in-line and supplies the tool_result itself once the user
|
|
531
|
+
// clicks. Branch BEFORE the generic toolCallElement so we never
|
|
532
|
+
// fall through to a JSON dump or, worse, get clobbered by the
|
|
533
|
+
// permission flow (which would render 🔐 three-button buttons that
|
|
534
|
+
// don't match the actual N options).
|
|
535
|
+
if (name === 'AskUserQuestion') {
|
|
536
|
+
const questions = Array.isArray(input?.questions) ? input.questions as cards.AskQuestion[] : []
|
|
537
|
+
this.pendingAsks.set(toolUseId, { questions, i })
|
|
538
|
+
const el = cards.askUserQuestionElement(i, toolUseId, questions, '🤔')
|
|
539
|
+
void cardkit.addElement(this.currentTurn.cardId, el, {
|
|
540
|
+
type: 'insert_before',
|
|
541
|
+
targetElementId: cards.ELEMENTS.footer,
|
|
542
|
+
})
|
|
543
|
+
return
|
|
544
|
+
}
|
|
545
|
+
// Pending Task* panels still show the *pre-op* todo mirror so users
|
|
546
|
+
// can read the current state immediately, without waiting for the
|
|
547
|
+
// tool to return.
|
|
548
|
+
const todos = this.isTaskWorkflow(name) ? this.todosArray() : undefined
|
|
549
|
+
const el = cards.toolCallElement(i, name, input, null, '⏳', undefined, todos)
|
|
354
550
|
void cardkit.addElement(this.currentTurn.cardId, el, {
|
|
355
551
|
type: 'insert_before',
|
|
356
552
|
targetElementId: cards.ELEMENTS.footer,
|
|
@@ -366,26 +562,141 @@ export class Session {
|
|
|
366
562
|
: Array.isArray(content)
|
|
367
563
|
? content.map((c: any) => c?.text ?? JSON.stringify(c)).join('\n')
|
|
368
564
|
: JSON.stringify(content)
|
|
369
|
-
|
|
565
|
+
// Stash on the meta — every Task* op coming after this point may
|
|
566
|
+
// need to re-render this panel with a fresher todo footer, so we
|
|
567
|
+
// can't discard the output after the first paint.
|
|
568
|
+
meta.output = output
|
|
569
|
+
meta.isError = isError
|
|
570
|
+
// Update the local todo mirror BEFORE rendering so the just-
|
|
571
|
+
// completed panel shows the new state too (e.g. a TaskCreate panel
|
|
572
|
+
// already lists the task it just created).
|
|
573
|
+
if (!isError && this.isTaskWorkflow(meta.name)) {
|
|
574
|
+
this.updateTodosFromTask(meta.name, meta.input, output)
|
|
575
|
+
}
|
|
576
|
+
const todos = this.isTaskWorkflow(meta.name) ? this.todosArray() : undefined
|
|
577
|
+
const el = cards.toolCallElement(meta.i, meta.name, meta.input, output, isError ? '❌' : '✅', meta.resolvedNote, todos)
|
|
370
578
|
void cardkit.replaceElement(this.currentTurn.cardId, cards.ELEMENTS.tool(meta.i), el)
|
|
579
|
+
// Cascade the new mirror into every prior Task* panel in this turn
|
|
580
|
+
// so any expanded panel reflects the latest state, not the snapshot
|
|
581
|
+
// captured when that op ran.
|
|
582
|
+
if (!isError && this.isTaskWorkflow(meta.name)) {
|
|
583
|
+
this.refreshOtherTaskPanels(toolUseId)
|
|
584
|
+
}
|
|
371
585
|
}
|
|
372
586
|
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
587
|
+
/** Roll a single Task* op into the local mirror — best-effort. Output
|
|
588
|
+
* parsing is regex-based (the SDK returns plain text like "Task #7
|
|
589
|
+
* created successfully: …"), so unexpected variants are skipped
|
|
590
|
+
* silently rather than blowing up the panel render. */
|
|
591
|
+
private updateTodosFromTask(name: string, input: any, output: string): void {
|
|
592
|
+
switch (name) {
|
|
593
|
+
case 'TaskCreate': {
|
|
594
|
+
const m = output.match(/Task #(\d+) created/)
|
|
595
|
+
if (!m) return
|
|
596
|
+
const id = Number(m[1])
|
|
597
|
+
this.currentTodos.set(id, {
|
|
598
|
+
id,
|
|
599
|
+
subject: input.subject,
|
|
600
|
+
description: input.description,
|
|
601
|
+
activeForm: input.activeForm,
|
|
602
|
+
status: 'pending',
|
|
603
|
+
})
|
|
604
|
+
return
|
|
605
|
+
}
|
|
606
|
+
case 'TaskUpdate': {
|
|
607
|
+
const id = Number(input.taskId)
|
|
608
|
+
if (!Number.isFinite(id)) return
|
|
609
|
+
// status=deleted is the SDK's tombstone — drop from the mirror
|
|
610
|
+
// so the readout doesn't carry it forever. Server still keeps
|
|
611
|
+
// it; the mirror is just for the panel footer.
|
|
612
|
+
if (input.status === 'deleted') { this.currentTodos.delete(id); return }
|
|
613
|
+
const cur = this.currentTodos.get(id) ?? { id, status: 'pending' as const }
|
|
614
|
+
if (input.status) cur.status = input.status
|
|
615
|
+
if (input.subject) cur.subject = input.subject
|
|
616
|
+
if (input.description) cur.description = input.description
|
|
617
|
+
if (input.owner) cur.owner = input.owner
|
|
618
|
+
if (input.activeForm) cur.activeForm = input.activeForm
|
|
619
|
+
this.currentTodos.set(id, cur)
|
|
620
|
+
return
|
|
621
|
+
}
|
|
622
|
+
// TaskList / TaskGet / TaskStop / TaskOutput / TaskDelete:
|
|
623
|
+
// read-only or parse-heavy — skip mirror update. The panel will
|
|
624
|
+
// still render the SDK's textual result below the operation
|
|
625
|
+
// block, which is enough to disambiguate.
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
/** Re-render every Task* panel in the current turn (except the one
|
|
630
|
+
* that just landed — already up-to-date) so they all show the latest
|
|
631
|
+
* todo mirror in their footers. Cheap: ELEMENTS.tool(i) replace is
|
|
632
|
+
* queued through the per-card Promise chain like any other op. */
|
|
633
|
+
private refreshOtherTaskPanels(skipToolUseId: string): void {
|
|
634
|
+
if (!this.currentTurn) return
|
|
635
|
+
const todos = this.todosArray()
|
|
636
|
+
for (const [id, meta] of this.currentTurn.toolByUseId) {
|
|
637
|
+
if (id === skipToolUseId) continue
|
|
638
|
+
if (!this.isTaskWorkflow(meta.name)) continue
|
|
639
|
+
const status: '⏳' | '✅' | '❌' = meta.output === undefined
|
|
640
|
+
? '⏳'
|
|
641
|
+
: (meta.isError ? '❌' : '✅')
|
|
642
|
+
const el = cards.toolCallElement(
|
|
643
|
+
meta.i, meta.name, meta.input, meta.output ?? null,
|
|
644
|
+
status, meta.resolvedNote, todos,
|
|
645
|
+
)
|
|
646
|
+
void cardkit.replaceElement(this.currentTurn.cardId, cards.ELEMENTS.tool(meta.i), el)
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
/** Merge the permission ask into the existing tool element in the
|
|
651
|
+
* current turn card. The user sees one continuous timeline: ⏳ pending
|
|
652
|
+
* → 🔐 awaiting approval (with buttons) → ⏳ allowed / ❌ denied → ✅
|
|
653
|
+
* with output. No floating orange card.
|
|
654
|
+
*
|
|
655
|
+
* `tool_use` is emitted as part of the assistant message and lands on
|
|
656
|
+
* our `addTool` handler BEFORE the SDK's `can_use_tool` control_request
|
|
657
|
+
* arrives — so by the time we get here, `toolByUseId` already has the
|
|
658
|
+
* entry we need to replace.
|
|
659
|
+
*
|
|
660
|
+
* Edge cases (no current turn / missing tool_use_id / unknown id) are
|
|
661
|
+
* surfaced loudly and auto-denied. We don't fall back to a standalone
|
|
662
|
+
* card — per the project's no-fallbacks rule, hidden anomalies are
|
|
663
|
+
* worse than visible deny errors. */
|
|
664
|
+
private renderPermission(req: CanUseToolRequest): void {
|
|
665
|
+
const turn = this.currentTurn
|
|
666
|
+
if (!turn) {
|
|
667
|
+
log(`session "${this.sessionName}": can_use_tool with no current turn — auto-deny req=${req.request_id}`)
|
|
668
|
+
this.proc?.sendPermissionResponse(req.request_id, 'deny', { denyMessage: 'no active turn' })
|
|
386
669
|
return
|
|
387
670
|
}
|
|
388
|
-
|
|
671
|
+
const toolUseId = req.tool_use_id
|
|
672
|
+
if (!toolUseId) {
|
|
673
|
+
log(`session "${this.sessionName}": can_use_tool without tool_use_id — auto-deny req=${req.request_id}`)
|
|
674
|
+
this.proc?.sendPermissionResponse(req.request_id, 'deny', { denyMessage: 'no tool_use_id' })
|
|
675
|
+
return
|
|
676
|
+
}
|
|
677
|
+
const meta = turn.toolByUseId.get(toolUseId)
|
|
678
|
+
if (!meta) {
|
|
679
|
+
log(`session "${this.sessionName}": can_use_tool for unknown tool_use_id=${toolUseId} — auto-deny req=${req.request_id}`)
|
|
680
|
+
this.proc?.sendPermissionResponse(req.request_id, 'deny', { denyMessage: 'unknown tool_use_id' })
|
|
681
|
+
return
|
|
682
|
+
}
|
|
683
|
+
// AskUserQuestion: daemon already rendered the choice UI in addTool
|
|
684
|
+
// and is collecting the user's answer asynchronously. The SDK
|
|
685
|
+
// wraps it in the standard permission flow anyway (even under
|
|
686
|
+
// bypassPermissions), so we auto-allow the request — otherwise
|
|
687
|
+
// Claude would block on can_use_tool forever, AND our permission
|
|
688
|
+
// renderer would overwrite the ask UI with the wrong 3-button
|
|
689
|
+
// panel. Keeping it allow-by-default also matches the user's
|
|
690
|
+
// mental model: they expect to choose an option, not "approve"
|
|
691
|
+
// the question itself.
|
|
692
|
+
if (meta.name === 'AskUserQuestion') {
|
|
693
|
+
this.proc?.sendPermissionResponse(req.request_id, 'allow')
|
|
694
|
+
return
|
|
695
|
+
}
|
|
696
|
+
this.status = 'awaiting_permission'
|
|
697
|
+
this.pendingPermissions.set(req.request_id, { toolUseId })
|
|
698
|
+
const el = cards.toolCallPermissionElement(meta.i, meta.name, meta.input, req.request_id)
|
|
699
|
+
void cardkit.replaceElement(turn.cardId, cards.ELEMENTS.tool(meta.i), el)
|
|
389
700
|
}
|
|
390
701
|
|
|
391
702
|
private async closeTurnCard(suffix?: string): Promise<void> {
|