@leviyuan/lodestar 0.1.0 → 0.1.1
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 +1 -0
- package/package.json +1 -1
- package/src/cards.ts +204 -97
- package/src/claude-process.ts +58 -8
- package/src/feishu.ts +37 -18
- package/src/paths.ts +2 -0
- package/src/session.ts +157 -28
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
|
@@ -152,6 +152,7 @@ function fmt(m: any[]): string {
|
|
|
152
152
|
async function boot(): Promise<void> {
|
|
153
153
|
log(`lodestar-daemon: pid ${process.pid} starting`)
|
|
154
154
|
feishu.loadSessionChatMap()
|
|
155
|
+
feishu.loadSessionResumeMap()
|
|
155
156
|
await feishu.refreshChatList()
|
|
156
157
|
setInterval(() => { void feishu.refreshChatList() }, 5 * 60 * 1000)
|
|
157
158
|
|
package/package.json
CHANGED
package/src/cards.ts
CHANGED
|
@@ -23,6 +23,13 @@ export const ELEMENTS = {
|
|
|
23
23
|
export function summarizeToolInput(name: string, input: any): string {
|
|
24
24
|
if (!input || typeof input !== 'object') return ''
|
|
25
25
|
const truncate = (s: string, n: number) => s.length > n ? s.slice(0, n) + '…' : s
|
|
26
|
+
// Task workflow tools (TaskCreate / TaskUpdate / TaskList / ...) carry
|
|
27
|
+
// structured fields that summarize much better as natural language than
|
|
28
|
+
// as truncated JSON. Routed first so they don't fall through to the
|
|
29
|
+
// generic Agent/Task case below.
|
|
30
|
+
if (name.startsWith('Task') && name !== 'Task') {
|
|
31
|
+
return truncate(summarizeTaskWorkflow(name, input), 80)
|
|
32
|
+
}
|
|
26
33
|
switch (name) {
|
|
27
34
|
case 'Bash': return truncate(String(input.command ?? ''), 80)
|
|
28
35
|
case 'Read':
|
|
@@ -44,6 +51,76 @@ export function summarizeToolInput(name: string, input: any): string {
|
|
|
44
51
|
return ''
|
|
45
52
|
}
|
|
46
53
|
|
|
54
|
+
/** Header summary for Task* workflow tools — `Task` (singular) is the
|
|
55
|
+
* separate subagent-spawn tool and is handled above; everything else
|
|
56
|
+
* (TaskCreate / TaskUpdate / TaskList / TaskGet / TaskStop / TaskOutput /
|
|
57
|
+
* TaskDelete) summarises through here. */
|
|
58
|
+
function summarizeTaskWorkflow(name: string, input: any): string {
|
|
59
|
+
switch (name) {
|
|
60
|
+
case 'TaskCreate':
|
|
61
|
+
return `📝 创建: ${input.subject ?? '(无 subject)'}`
|
|
62
|
+
case 'TaskUpdate': {
|
|
63
|
+
const parts: string[] = []
|
|
64
|
+
if (input.status) parts.push(`→ ${input.status}`)
|
|
65
|
+
if (input.owner) parts.push(`owner=${input.owner}`)
|
|
66
|
+
if (input.subject) parts.push(`subject="${input.subject}"`)
|
|
67
|
+
if (input.addBlocks) parts.push(`blocks=[${(input.addBlocks ?? []).join(',')}]`)
|
|
68
|
+
if (input.addBlockedBy) parts.push(`blockedBy=[${(input.addBlockedBy ?? []).join(',')}]`)
|
|
69
|
+
const tail = parts.length ? ' ' + parts.join(', ') : ''
|
|
70
|
+
return `✏️ #${input.taskId ?? '?'}${tail}`
|
|
71
|
+
}
|
|
72
|
+
case 'TaskList': return '📋 查询任务列表'
|
|
73
|
+
case 'TaskGet': return `🔍 查询 #${input.taskId ?? '?'}`
|
|
74
|
+
case 'TaskStop': return `⏹ 停止 #${input.taskId ?? '?'}`
|
|
75
|
+
case 'TaskOutput': return `📤 取输出 #${input.taskId ?? '?'}`
|
|
76
|
+
case 'TaskDelete': return `🗑 删除 #${input.taskId ?? '?'}`
|
|
77
|
+
}
|
|
78
|
+
return name
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Markdown body for Task* workflow tools — replaces the generic JSON
|
|
82
|
+
* dump with a human-readable description of the operation plus, once the
|
|
83
|
+
* 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
|
+
function renderTaskWorkflowBody(name: string, input: any, output: string | null): string {
|
|
86
|
+
const lines: string[] = []
|
|
87
|
+
switch (name) {
|
|
88
|
+
case 'TaskCreate':
|
|
89
|
+
lines.push(`**📝 创建任务**`)
|
|
90
|
+
if (input.subject) lines.push(`- subject: ${input.subject}`)
|
|
91
|
+
if (input.description) lines.push(`- 描述: ${input.description}`)
|
|
92
|
+
if (input.activeForm) lines.push(`- 进行时: ${input.activeForm}`)
|
|
93
|
+
break
|
|
94
|
+
case 'TaskUpdate': {
|
|
95
|
+
lines.push(`**✏️ 更新 #${input.taskId ?? '?'}**`)
|
|
96
|
+
if (input.status) lines.push(`- status → \`${input.status}\``)
|
|
97
|
+
if (input.subject) lines.push(`- subject: ${input.subject}`)
|
|
98
|
+
if (input.description) lines.push(`- description: ${input.description}`)
|
|
99
|
+
if (input.owner) lines.push(`- owner: ${input.owner}`)
|
|
100
|
+
if (input.activeForm) lines.push(`- 进行时: ${input.activeForm}`)
|
|
101
|
+
if (input.addBlocks) lines.push(`- blocks → ${(input.addBlocks).join(', ')}`)
|
|
102
|
+
if (input.addBlockedBy) lines.push(`- blockedBy → ${(input.addBlockedBy).join(', ')}`)
|
|
103
|
+
if (input.metadata) lines.push(`- metadata: \`${JSON.stringify(input.metadata)}\``)
|
|
104
|
+
break
|
|
105
|
+
}
|
|
106
|
+
case 'TaskList': lines.push('**📋 查询当前任务清单**'); break
|
|
107
|
+
case 'TaskGet': lines.push(`**🔍 查询 #${input.taskId ?? '?'}**`); break
|
|
108
|
+
case 'TaskStop': lines.push(`**⏹ 停止 #${input.taskId ?? '?'}**`); break
|
|
109
|
+
case 'TaskOutput': lines.push(`**📤 取 #${input.taskId ?? '?'} 输出**`); break
|
|
110
|
+
case 'TaskDelete': lines.push(`**🗑 删除 #${input.taskId ?? '?'}**`); break
|
|
111
|
+
default:
|
|
112
|
+
lines.push(`**${name}**`)
|
|
113
|
+
lines.push('```\n' + JSON.stringify(input ?? {}, null, 2).slice(0, 1000) + '\n```')
|
|
114
|
+
}
|
|
115
|
+
if (output != null) {
|
|
116
|
+
lines.push('')
|
|
117
|
+
lines.push('---')
|
|
118
|
+
lines.push('**结果**')
|
|
119
|
+
lines.push(output.slice(0, 3000))
|
|
120
|
+
}
|
|
121
|
+
return lines.join('\n')
|
|
122
|
+
}
|
|
123
|
+
|
|
47
124
|
interface MainCardOpts {
|
|
48
125
|
sessionName: string
|
|
49
126
|
turn: number
|
|
@@ -100,70 +177,79 @@ export function thinkingCollapsedPanel(fullText: string): object {
|
|
|
100
177
|
}
|
|
101
178
|
}
|
|
102
179
|
|
|
103
|
-
/** Element to insert for each tool call.
|
|
180
|
+
/** Element to insert for each tool call. Expandable for big results.
|
|
104
181
|
*
|
|
105
182
|
* Header is a one-line summary: status + name + summarized input.
|
|
106
|
-
* Body holds the full input + (after completion) the full output.
|
|
183
|
+
* Body holds the full input + (after completion) the full output.
|
|
184
|
+
* `resolvedNote` is an optional one-liner appended below the input —
|
|
185
|
+
* used to surface "✅ 允许 by Alice" inline after a permission decision
|
|
186
|
+
* lands but before the actual tool execution completes. */
|
|
107
187
|
export function toolCallElement(
|
|
108
188
|
i: number,
|
|
109
189
|
name: string,
|
|
110
190
|
input: any,
|
|
111
191
|
output: string | null,
|
|
112
192
|
status: '⏳' | '✅' | '❌' = '⏳',
|
|
193
|
+
resolvedNote?: string,
|
|
113
194
|
): object {
|
|
114
195
|
const summary = summarizeToolInput(name, input)
|
|
115
196
|
const headerText = summary
|
|
116
197
|
? `${status} 🔧 ${name}: ${summary}`
|
|
117
198
|
: `${status} 🔧 ${name}`
|
|
118
|
-
const
|
|
119
|
-
const
|
|
120
|
-
|
|
121
|
-
|
|
199
|
+
const isTaskWorkflow = name.startsWith('Task') && name !== 'Task'
|
|
200
|
+
const noteBlock = resolvedNote ? `\n\n${resolvedNote}` : ''
|
|
201
|
+
// Task* gets a narrative body (operation + result), the rest keeps the
|
|
202
|
+
// JSON-input + raw-output split — generic dump is better for unfamiliar
|
|
203
|
+
// tools where users can't predict what fields matter.
|
|
204
|
+
const body = isTaskWorkflow
|
|
205
|
+
? renderTaskWorkflowBody(name, input, output) + noteBlock
|
|
206
|
+
: '```\n' + JSON.stringify(input ?? {}, null, 2).slice(0, 2000) + '\n```'
|
|
207
|
+
+ noteBlock
|
|
208
|
+
+ (output != null ? '\n---\n**output:**\n```\n' + output.slice(0, 3000) + '\n```' : '')
|
|
122
209
|
return {
|
|
123
210
|
tag: 'collapsible_panel',
|
|
124
211
|
element_id: ELEMENTS.tool(i),
|
|
125
212
|
header: { title: { tag: 'plain_text', content: headerText } },
|
|
126
213
|
expanded: false,
|
|
127
214
|
elements: [
|
|
128
|
-
{ tag: 'markdown', content:
|
|
215
|
+
{ tag: 'markdown', content: body },
|
|
129
216
|
],
|
|
130
217
|
}
|
|
131
218
|
}
|
|
132
219
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
220
|
+
/** Same tool panel as `toolCallElement`, but with the 🔐 status and
|
|
221
|
+
* three inline action buttons (allow / allow_always / deny). Expanded
|
|
222
|
+
* by default so the user can read the request without clicking through.
|
|
223
|
+
* This is the "merge into tool panel" UX — the permission decision
|
|
224
|
+
* lives on the same row as the tool call instead of as a separate
|
|
225
|
+
* floating card. */
|
|
226
|
+
export function toolCallPermissionElement(
|
|
227
|
+
i: number,
|
|
228
|
+
name: string,
|
|
229
|
+
input: any,
|
|
230
|
+
requestId: string,
|
|
231
|
+
): object {
|
|
232
|
+
const summary = summarizeToolInput(name, input)
|
|
233
|
+
const headerText = summary
|
|
234
|
+
? `🔐 等审批 · ${name}: ${summary}`
|
|
235
|
+
: `🔐 等审批 · ${name}`
|
|
236
|
+
const inputBlock = '```\n' + JSON.stringify(input ?? {}, null, 2).slice(0, 2000) + '\n```'
|
|
145
237
|
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
|
-
},
|
|
238
|
+
tag: 'collapsible_panel',
|
|
239
|
+
element_id: ELEMENTS.tool(i),
|
|
240
|
+
header: { title: { tag: 'plain_text', content: headerText } },
|
|
241
|
+
expanded: true,
|
|
242
|
+
elements: [
|
|
243
|
+
{ tag: 'markdown', content: inputBlock },
|
|
244
|
+
{
|
|
245
|
+
tag: 'column_set',
|
|
246
|
+
columns: [
|
|
247
|
+
permissionButtonColumn('✅ 允许', 'primary', requestId, 'allow'),
|
|
248
|
+
permissionButtonColumn('♾️ 始终允许', 'default', requestId, 'allow_always'),
|
|
249
|
+
permissionButtonColumn('❌ 拒绝', 'danger', requestId, 'deny'),
|
|
250
|
+
],
|
|
251
|
+
},
|
|
252
|
+
],
|
|
167
253
|
}
|
|
168
254
|
}
|
|
169
255
|
|
|
@@ -179,64 +265,97 @@ function permissionButtonColumn(label: string, type: string, requestId: string,
|
|
|
179
265
|
}
|
|
180
266
|
}
|
|
181
267
|
|
|
182
|
-
export function permissionResolvedCard(
|
|
183
|
-
toolName: string,
|
|
184
|
-
decision: 'allow' | 'allow_always' | 'deny',
|
|
185
|
-
user: string,
|
|
186
|
-
): object {
|
|
187
|
-
const ok = decision !== 'deny'
|
|
188
|
-
const label = decision === 'allow_always' ? '始终允许' : decision === 'allow' ? '已允许' : '已拒绝'
|
|
189
|
-
return {
|
|
190
|
-
schema: '2.0',
|
|
191
|
-
config: { update_multi: true },
|
|
192
|
-
header: {
|
|
193
|
-
title: { tag: 'plain_text', content: `🔐 权限请求 · ${toolName}` },
|
|
194
|
-
template: ok ? 'green' : 'red',
|
|
195
|
-
},
|
|
196
|
-
body: {
|
|
197
|
-
elements: [{
|
|
198
|
-
tag: 'markdown',
|
|
199
|
-
content: `${ok ? '✅' : '❌'} **${label}** by ${user || '匿名'} · ${new Date().toLocaleTimeString('zh-CN', { hour12: false })}`,
|
|
200
|
-
}],
|
|
201
|
-
},
|
|
202
|
-
}
|
|
203
|
-
}
|
|
204
|
-
|
|
205
268
|
interface ConsoleOpts {
|
|
206
269
|
sessionName: string
|
|
207
270
|
status: 'idle' | 'working' | 'awaiting_permission' | 'starting' | 'stopped'
|
|
208
271
|
model?: string
|
|
209
272
|
effort?: string
|
|
210
|
-
|
|
211
|
-
|
|
273
|
+
/** ms since this ClaudeProcess spawned — formatted to "1h 23m" inside. */
|
|
274
|
+
uptimeMs?: number
|
|
275
|
+
/** Current context-window occupancy estimate (input + cache tokens of
|
|
276
|
+
* the last assistant message). 0 if no turn has completed yet. */
|
|
277
|
+
contextTokens?: number
|
|
278
|
+
/** Window upper bound. Defaults to 1M (claude-opus-4-7[1m]). */
|
|
279
|
+
contextLimit?: number
|
|
280
|
+
cumStats?: { tokens: number; costUsd: number; turns: number }
|
|
281
|
+
lastTurn?: { tokens: number; costUsd: number; durationMs: number }
|
|
282
|
+
sessionId?: string | null
|
|
212
283
|
hasSession: boolean
|
|
213
284
|
}
|
|
214
285
|
|
|
286
|
+
/** Format token counts as a compact human-readable string: 1,234 → 1.2K. */
|
|
287
|
+
function fmtTokens(n: number): string {
|
|
288
|
+
if (!n) return '0'
|
|
289
|
+
if (n < 1000) return String(n)
|
|
290
|
+
if (n < 1_000_000) return (n / 1000).toFixed(n < 10_000 ? 1 : 0).replace(/\.0$/, '') + 'K'
|
|
291
|
+
return (n / 1_000_000).toFixed(2).replace(/\.?0+$/, '') + 'M'
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function fmtCost(c: number): string {
|
|
295
|
+
if (c < 0.01) return `$${c.toFixed(4)}`
|
|
296
|
+
return `$${c.toFixed(2)}`
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function fmtDurationMs(ms: number): string {
|
|
300
|
+
if (ms < 1000) return `${ms}ms`
|
|
301
|
+
if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`
|
|
302
|
+
const m = Math.floor(ms / 60_000)
|
|
303
|
+
const s = Math.round((ms % 60_000) / 1000)
|
|
304
|
+
return `${m}m ${s}s`
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function fmtUptime(ms: number): string {
|
|
308
|
+
if (ms < 60_000) return `${Math.round(ms / 1000)}s`
|
|
309
|
+
const totalMin = Math.floor(ms / 60_000)
|
|
310
|
+
if (totalMin < 60) return `${totalMin}m`
|
|
311
|
+
const h = Math.floor(totalMin / 60)
|
|
312
|
+
const m = totalMin % 60
|
|
313
|
+
if (h < 24) return `${h}h ${m}m`
|
|
314
|
+
const d = Math.floor(h / 24)
|
|
315
|
+
return `${d}d ${h % 24}h`
|
|
316
|
+
}
|
|
317
|
+
|
|
215
318
|
export function consoleCard(opts: ConsoleOpts): object {
|
|
216
|
-
const {
|
|
319
|
+
const {
|
|
320
|
+
sessionName, status, model, effort, uptimeMs,
|
|
321
|
+
contextTokens, contextLimit, cumStats, lastTurn, sessionId, hasSession,
|
|
322
|
+
} = opts
|
|
217
323
|
const statusEmoji = {
|
|
218
324
|
idle: '🟢 闲', working: '⚙️ 工作中', awaiting_permission: '🔐 等审批',
|
|
219
325
|
starting: '🚀 启动中', stopped: '⚪ 未运行',
|
|
220
326
|
}[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
327
|
|
|
228
|
-
const
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
328
|
+
const modelLine = model ? `${model}${effort ? `/${effort}` : ''}` : null
|
|
329
|
+
const headerLine = [statusEmoji, modelLine].filter(Boolean).join(' · ')
|
|
330
|
+
|
|
331
|
+
// Build the metric lines that make this panel useful. Each is "label
|
|
332
|
+
// <tab> value" rendered as plain markdown — keeps it readable inside
|
|
333
|
+
// the small Feishu card area without competing with the button row.
|
|
334
|
+
const lines: string[] = [headerLine]
|
|
335
|
+
|
|
336
|
+
if (contextTokens != null) {
|
|
337
|
+
const limit = contextLimit ?? 1_000_000
|
|
338
|
+
const pct = limit > 0 ? Math.round((contextTokens / limit) * 100) : 0
|
|
339
|
+
lines.push(`**📦 上下文** ${fmtTokens(contextTokens)} / ${fmtTokens(limit)} (${pct}%)`)
|
|
340
|
+
}
|
|
341
|
+
if (uptimeMs != null && uptimeMs > 0) {
|
|
342
|
+
lines.push(`**⏱ 已运行** ${fmtUptime(uptimeMs)}`)
|
|
343
|
+
}
|
|
344
|
+
if (cumStats && (cumStats.tokens > 0 || cumStats.costUsd > 0 || cumStats.turns > 0)) {
|
|
345
|
+
lines.push(`**💬 累计** ${fmtTokens(cumStats.tokens)} tokens · ${fmtCost(cumStats.costUsd)} · ${cumStats.turns} turn${cumStats.turns === 1 ? '' : 's'}`)
|
|
346
|
+
}
|
|
347
|
+
if (lastTurn) {
|
|
348
|
+
lines.push(`**🔄 上一轮** +${fmtTokens(lastTurn.tokens)} · ${fmtCost(lastTurn.costUsd)} · ${fmtDurationMs(lastTurn.durationMs)}`)
|
|
349
|
+
}
|
|
350
|
+
if (sessionId) {
|
|
351
|
+
lines.push(`**🆔 session** \`${sessionId.slice(0, 8)}…\``)
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
void hasSession // accept the field for caller compat; lifecycle is now
|
|
355
|
+
// driven by bare-word commands (`hi` / `kill` / `restart` / `clear`),
|
|
356
|
+
// not buttons — keeps the panel pure-readout and one-handed mobile-
|
|
357
|
+
// friendly. The 'refresh' / 'ls' actions stay in onConsoleAction for
|
|
358
|
+
// backward compat with any still-floating older cards in chat history.
|
|
240
359
|
|
|
241
360
|
const template = status === 'working' ? 'blue'
|
|
242
361
|
: status === 'awaiting_permission' ? 'orange'
|
|
@@ -252,19 +371,7 @@ export function consoleCard(opts: ConsoleOpts): object {
|
|
|
252
371
|
},
|
|
253
372
|
body: {
|
|
254
373
|
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
|
-
},
|
|
374
|
+
{ tag: 'markdown', content: lines.join('\n\n') },
|
|
268
375
|
],
|
|
269
376
|
},
|
|
270
377
|
}
|
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,28 @@ 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
|
-
hookSpecificOutput: {
|
|
256
|
-
hookEventName: 'PreToolUse',
|
|
257
|
-
permissionDecision: decision,
|
|
258
|
-
...(updatedInput ? { permissionDecisionInput: updatedInput } : {}),
|
|
259
|
-
},
|
|
260
|
-
},
|
|
310
|
+
response,
|
|
261
311
|
},
|
|
262
312
|
})
|
|
263
313
|
}
|
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,7 @@ interface TurnState {
|
|
|
22
22
|
userText: string
|
|
23
23
|
thinkingText: string
|
|
24
24
|
toolCount: number
|
|
25
|
-
toolByUseId: Map<string, { i: number; name: string; input: any }>
|
|
25
|
+
toolByUseId: Map<string, { i: number; name: string; input: any; resolvedNote?: string }>
|
|
26
26
|
assistantSegmentCount: number
|
|
27
27
|
currentAssistantSegmentId: string | null
|
|
28
28
|
currentAssistantText: string
|
|
@@ -41,23 +41,53 @@ export interface SessionOpts {
|
|
|
41
41
|
permissionMode?: 'default' | 'acceptEdits' | 'plan' | 'bypassPermissions'
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
/** Per-turn delta extracted from the SDK `result` message — feeds the
|
|
45
|
+
* "上一轮" line in the console panel. */
|
|
46
|
+
interface LastTurnDelta {
|
|
47
|
+
tokens: number // input + cache_* + output for that turn
|
|
48
|
+
costUsd: number
|
|
49
|
+
durationMs: number
|
|
50
|
+
inputTokens: number // input + cache_* (excludes output) — context-window estimate
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Cumulative session counters. Reset on full restart (`clear`), preserved
|
|
54
|
+
* across resume — but resumed conversations start counting from the
|
|
55
|
+
* resume point, not the original turn 0; the SDK doesn't replay historical
|
|
56
|
+
* usage. The session_id continuity is preserved separately by the resume
|
|
57
|
+
* map; cumStats represents "since the current ClaudeProcess was spawned". */
|
|
58
|
+
interface CumStats {
|
|
59
|
+
tokens: number
|
|
60
|
+
costUsd: number
|
|
61
|
+
turns: number
|
|
62
|
+
}
|
|
63
|
+
|
|
44
64
|
export class Session {
|
|
45
65
|
private proc: ClaudeProcess | null = null
|
|
46
66
|
private currentTurn: TurnState | null = null
|
|
47
|
-
private pendingPermissions = new Map<string, {
|
|
67
|
+
private pendingPermissions = new Map<string, { toolUseId: string }>()
|
|
48
68
|
private turnCounter = 0
|
|
49
69
|
// Last seen sessionId — preserved across `kill`/`stop` so a later
|
|
50
70
|
// `restart` can resume the same Claude conversation even after the
|
|
51
71
|
// child process is gone.
|
|
52
72
|
private lastSessionId: string | null = null
|
|
53
73
|
private startedAt: number = 0
|
|
74
|
+
private cumStats: CumStats = { tokens: 0, costUsd: 0, turns: 0 }
|
|
75
|
+
private lastTurnDelta: LastTurnDelta | null = null
|
|
54
76
|
status: Status = 'stopped'
|
|
55
77
|
|
|
56
78
|
constructor(
|
|
57
79
|
public readonly sessionName: string,
|
|
58
80
|
public readonly chatId: string,
|
|
59
81
|
private opts: SessionOpts = {},
|
|
60
|
-
) {
|
|
82
|
+
) {
|
|
83
|
+
// Restore last-known claude session_id from disk so a daemon restart
|
|
84
|
+
// (systemctl, crash, watchdog) doesn't strand the user with a fresh
|
|
85
|
+
// conversation when they next type `restart`.
|
|
86
|
+
this.lastSessionId = feishu.getSessionResume(sessionName)
|
|
87
|
+
if (this.lastSessionId) {
|
|
88
|
+
log(`session "${sessionName}": restored lastSessionId=${this.lastSessionId.slice(0, 8)}…`)
|
|
89
|
+
}
|
|
90
|
+
}
|
|
61
91
|
|
|
62
92
|
get workDir(): string { return join(feishu.PROJECTS_ROOT, this.sessionName) }
|
|
63
93
|
isRunning(): boolean { return !!this.proc && this.proc.isAlive() }
|
|
@@ -82,7 +112,7 @@ export class Session {
|
|
|
82
112
|
this.proc = new ClaudeProcess({
|
|
83
113
|
workDir: this.workDir,
|
|
84
114
|
effort: 'max',
|
|
85
|
-
permissionMode: this.opts.permissionMode ?? '
|
|
115
|
+
permissionMode: this.opts.permissionMode ?? 'bypassPermissions',
|
|
86
116
|
appendSystemPrompt: CHANNEL_INSTRUCTIONS,
|
|
87
117
|
})
|
|
88
118
|
this.wireProc(this.proc)
|
|
@@ -122,7 +152,7 @@ export class Session {
|
|
|
122
152
|
this.proc = new ClaudeProcess({
|
|
123
153
|
workDir: this.workDir,
|
|
124
154
|
effort: 'max',
|
|
125
|
-
permissionMode: '
|
|
155
|
+
permissionMode: this.opts.permissionMode ?? 'bypassPermissions',
|
|
126
156
|
resumeSessionId: prevSessionId,
|
|
127
157
|
appendSystemPrompt: CHANNEL_INSTRUCTIONS,
|
|
128
158
|
})
|
|
@@ -132,6 +162,16 @@ export class Session {
|
|
|
132
162
|
this.startedAt = Date.now()
|
|
133
163
|
await feishu.sendText(this.chatId, `🔁 已重启并恢复 session=${prevSessionId.slice(0, 8)}…`)
|
|
134
164
|
} else {
|
|
165
|
+
// Resume requested but no prior session_id on file — surface it
|
|
166
|
+
// explicitly rather than silently fresh-starting (the old behavior
|
|
167
|
+
// hid the daemon-restart sessionId-loss bug for months).
|
|
168
|
+
if (resume) {
|
|
169
|
+
await feishu.sendText(this.chatId, '⚠️ 没有可恢复的上一会话,将以新会话启动')
|
|
170
|
+
}
|
|
171
|
+
// Fresh conversation — drop cumulative stats so the next `hi` shows
|
|
172
|
+
// zeroed counters instead of bleeding numbers from the prior chat.
|
|
173
|
+
this.cumStats = { tokens: 0, costUsd: 0, turns: 0 }
|
|
174
|
+
this.lastTurnDelta = null
|
|
135
175
|
await this.start()
|
|
136
176
|
}
|
|
137
177
|
}
|
|
@@ -168,14 +208,27 @@ export class Session {
|
|
|
168
208
|
}
|
|
169
209
|
|
|
170
210
|
async showConsole(): Promise<void> {
|
|
171
|
-
const
|
|
172
|
-
|
|
173
|
-
|
|
211
|
+
const uptimeMs = this.startedAt ? (Date.now() - this.startedAt) : undefined
|
|
212
|
+
// Strip the `claude-` prefix so the panel stays compact: `opus-4-7`
|
|
213
|
+
// reads better than `claude-opus-4-7` in the small status header.
|
|
214
|
+
const rawModel = this.proc?.lastModel ?? null
|
|
215
|
+
const model = rawModel ? rawModel.replace(/^claude-/, '') : undefined
|
|
174
216
|
const card = cards.consoleCard({
|
|
175
217
|
sessionName: this.sessionName,
|
|
176
218
|
status: this.status,
|
|
219
|
+
model,
|
|
177
220
|
effort: 'max',
|
|
178
|
-
|
|
221
|
+
uptimeMs,
|
|
222
|
+
contextTokens: this.currentContextTokens(),
|
|
223
|
+
cumStats: this.cumStats,
|
|
224
|
+
lastTurn: this.lastTurnDelta
|
|
225
|
+
? {
|
|
226
|
+
tokens: this.lastTurnDelta.tokens,
|
|
227
|
+
costUsd: this.lastTurnDelta.costUsd,
|
|
228
|
+
durationMs: this.lastTurnDelta.durationMs,
|
|
229
|
+
}
|
|
230
|
+
: undefined,
|
|
231
|
+
sessionId: this.proc?.sessionId ?? this.lastSessionId,
|
|
179
232
|
hasSession: this.isRunning(),
|
|
180
233
|
})
|
|
181
234
|
await feishu.sendCard(this.chatId, card)
|
|
@@ -212,8 +265,21 @@ export class Session {
|
|
|
212
265
|
if (!pending) { log(`session "${this.sessionName}": stray permission ${requestId}`); return }
|
|
213
266
|
this.pendingPermissions.delete(requestId)
|
|
214
267
|
|
|
215
|
-
|
|
216
|
-
|
|
268
|
+
// Update the tool element in the main turn card in place — the
|
|
269
|
+
// permission decision lives on the same row as the tool call.
|
|
270
|
+
const turn = this.currentTurn
|
|
271
|
+
const meta = turn?.toolByUseId.get(pending.toolUseId)
|
|
272
|
+
if (turn && meta) {
|
|
273
|
+
if (decision === 'deny') {
|
|
274
|
+
const el = cards.toolCallElement(meta.i, meta.name, meta.input, `🚫 已拒绝 by ${user || '匿名'}`, '❌')
|
|
275
|
+
void cardkit.replaceElement(turn.cardId, cards.ELEMENTS.tool(meta.i), el)
|
|
276
|
+
} else {
|
|
277
|
+
const label = decision === 'allow_always' ? '始终允许' : '已允许'
|
|
278
|
+
meta.resolvedNote = `✅ **${label}** by ${user || '匿名'}`
|
|
279
|
+
const el = cards.toolCallElement(meta.i, meta.name, meta.input, null, '⏳', meta.resolvedNote)
|
|
280
|
+
void cardkit.replaceElement(turn.cardId, cards.ELEMENTS.tool(meta.i), el)
|
|
281
|
+
}
|
|
282
|
+
}
|
|
217
283
|
|
|
218
284
|
const claudeDecision = decision === 'deny' ? 'deny' : 'allow'
|
|
219
285
|
this.proc?.sendPermissionResponse(requestId, claudeDecision)
|
|
@@ -235,12 +301,22 @@ export class Session {
|
|
|
235
301
|
case 'stop': await this.stop(); break
|
|
236
302
|
case 'start': await this.start(); break
|
|
237
303
|
case 'resume': await this.restart(true); break
|
|
304
|
+
case 'refresh': await this.showConsole(); break
|
|
238
305
|
case 'ls': await feishu.sendText(this.chatId, `📁 ${this.workDir}`); break
|
|
239
306
|
}
|
|
240
307
|
}
|
|
241
308
|
|
|
242
309
|
// ── Wiring Claude → Feishu ─────────────────────────────────────────
|
|
243
310
|
private wireProc(p: ClaudeProcess): void {
|
|
311
|
+
p.on('init', () => {
|
|
312
|
+
// Persist the freshly assigned session_id so a later daemon
|
|
313
|
+
// restart can resume this conversation. Skip if unchanged to
|
|
314
|
+
// avoid hammering the file on every init for resumed sessions.
|
|
315
|
+
if (p.sessionId && p.sessionId !== this.lastSessionId) {
|
|
316
|
+
this.lastSessionId = p.sessionId
|
|
317
|
+
feishu.bindSessionResume(this.sessionName, p.sessionId)
|
|
318
|
+
}
|
|
319
|
+
})
|
|
244
320
|
p.on('assistant_text', ({ text }: { text: string }) => {
|
|
245
321
|
this.appendAssistant(text)
|
|
246
322
|
})
|
|
@@ -254,13 +330,14 @@ export class Session {
|
|
|
254
330
|
this.completeTool(tool_use_id, content, is_error)
|
|
255
331
|
})
|
|
256
332
|
p.on('can_use_tool', (req: CanUseToolRequest) => {
|
|
257
|
-
|
|
333
|
+
this.renderPermission(req)
|
|
258
334
|
})
|
|
259
335
|
p.on('hook_callback', (req: HookCallbackRequest) => {
|
|
260
336
|
// No hooks registered → fail-safe ack.
|
|
261
337
|
this.proc?.sendHookResponse(req.request_id, {})
|
|
262
338
|
})
|
|
263
339
|
p.on('result', () => {
|
|
340
|
+
this.accumulateResultStats()
|
|
264
341
|
void this.closeTurnCard()
|
|
265
342
|
this.status = 'idle'
|
|
266
343
|
})
|
|
@@ -275,6 +352,37 @@ export class Session {
|
|
|
275
352
|
})
|
|
276
353
|
}
|
|
277
354
|
|
|
355
|
+
/** Pull per-turn numbers off `proc.lastResult` (set by ClaudeProcess when
|
|
356
|
+
* the `result` message landed) and roll them into cumStats + the
|
|
357
|
+
* "上一轮" delta. Called exactly once per result event, right before
|
|
358
|
+
* closeTurnCard. */
|
|
359
|
+
private accumulateResultStats(): void {
|
|
360
|
+
const r = this.proc?.lastResult
|
|
361
|
+
if (!r) return
|
|
362
|
+
const u = r.usage ?? {}
|
|
363
|
+
const inputTokens = (u.input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0) + (u.cache_read_input_tokens ?? 0)
|
|
364
|
+
const outputTokens = u.output_tokens ?? 0
|
|
365
|
+
const tokens = inputTokens + outputTokens
|
|
366
|
+
const costUsd = r.cost_usd ?? 0
|
|
367
|
+
const durationMs = r.duration_ms ?? 0
|
|
368
|
+
this.cumStats.tokens += tokens
|
|
369
|
+
this.cumStats.costUsd += costUsd
|
|
370
|
+
this.cumStats.turns += r.num_turns ?? 1
|
|
371
|
+
this.lastTurnDelta = { tokens, costUsd, durationMs, inputTokens }
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/** Current context-window occupancy estimate — uses the most recent
|
|
375
|
+
* assistant `usage` (input + caches), since each assistant reply replays
|
|
376
|
+
* the full conversation. Falls back to the last-turn delta when no
|
|
377
|
+
* assistant message has streamed yet this process. */
|
|
378
|
+
private currentContextTokens(): number {
|
|
379
|
+
const u = this.proc?.lastUsage as ClaudeUsage | null | undefined
|
|
380
|
+
if (u) {
|
|
381
|
+
return (u.input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0) + (u.cache_read_input_tokens ?? 0)
|
|
382
|
+
}
|
|
383
|
+
return this.lastTurnDelta?.inputTokens ?? 0
|
|
384
|
+
}
|
|
385
|
+
|
|
278
386
|
private async openTurnCard(userText: string): Promise<void> {
|
|
279
387
|
const turn = ++this.turnCounter
|
|
280
388
|
const card = cards.mainConversationCard({
|
|
@@ -366,26 +474,47 @@ export class Session {
|
|
|
366
474
|
: Array.isArray(content)
|
|
367
475
|
? content.map((c: any) => c?.text ?? JSON.stringify(c)).join('\n')
|
|
368
476
|
: JSON.stringify(content)
|
|
369
|
-
const el = cards.toolCallElement(meta.i, meta.name, meta.input, output, isError ? '❌' : '✅')
|
|
477
|
+
const el = cards.toolCallElement(meta.i, meta.name, meta.input, output, isError ? '❌' : '✅', meta.resolvedNote)
|
|
370
478
|
void cardkit.replaceElement(this.currentTurn.cardId, cards.ELEMENTS.tool(meta.i), el)
|
|
371
479
|
}
|
|
372
480
|
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
481
|
+
/** Merge the permission ask into the existing tool element in the
|
|
482
|
+
* current turn card. The user sees one continuous timeline: ⏳ pending
|
|
483
|
+
* → 🔐 awaiting approval (with buttons) → ⏳ allowed / ❌ denied → ✅
|
|
484
|
+
* with output. No floating orange card.
|
|
485
|
+
*
|
|
486
|
+
* `tool_use` is emitted as part of the assistant message and lands on
|
|
487
|
+
* our `addTool` handler BEFORE the SDK's `can_use_tool` control_request
|
|
488
|
+
* arrives — so by the time we get here, `toolByUseId` already has the
|
|
489
|
+
* entry we need to replace.
|
|
490
|
+
*
|
|
491
|
+
* Edge cases (no current turn / missing tool_use_id / unknown id) are
|
|
492
|
+
* surfaced loudly and auto-denied. We don't fall back to a standalone
|
|
493
|
+
* card — per the project's no-fallbacks rule, hidden anomalies are
|
|
494
|
+
* worse than visible deny errors. */
|
|
495
|
+
private renderPermission(req: CanUseToolRequest): void {
|
|
496
|
+
const turn = this.currentTurn
|
|
497
|
+
if (!turn) {
|
|
498
|
+
log(`session "${this.sessionName}": can_use_tool with no current turn — auto-deny req=${req.request_id}`)
|
|
499
|
+
this.proc?.sendPermissionResponse(req.request_id, 'deny', { denyMessage: 'no active turn' })
|
|
500
|
+
return
|
|
501
|
+
}
|
|
502
|
+
const toolUseId = req.tool_use_id
|
|
503
|
+
if (!toolUseId) {
|
|
504
|
+
log(`session "${this.sessionName}": can_use_tool without tool_use_id — auto-deny req=${req.request_id}`)
|
|
505
|
+
this.proc?.sendPermissionResponse(req.request_id, 'deny', { denyMessage: 'no tool_use_id' })
|
|
386
506
|
return
|
|
387
507
|
}
|
|
388
|
-
|
|
508
|
+
const meta = turn.toolByUseId.get(toolUseId)
|
|
509
|
+
if (!meta) {
|
|
510
|
+
log(`session "${this.sessionName}": can_use_tool for unknown tool_use_id=${toolUseId} — auto-deny req=${req.request_id}`)
|
|
511
|
+
this.proc?.sendPermissionResponse(req.request_id, 'deny', { denyMessage: 'unknown tool_use_id' })
|
|
512
|
+
return
|
|
513
|
+
}
|
|
514
|
+
this.status = 'awaiting_permission'
|
|
515
|
+
this.pendingPermissions.set(req.request_id, { toolUseId })
|
|
516
|
+
const el = cards.toolCallPermissionElement(meta.i, meta.name, meta.input, req.request_id)
|
|
517
|
+
void cardkit.replaceElement(turn.cardId, cards.ELEMENTS.tool(meta.i), el)
|
|
389
518
|
}
|
|
390
519
|
|
|
391
520
|
private async closeTurnCard(suffix?: string): Promise<void> {
|