@leviyuan/lodestar 0.2.7 → 0.2.9

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 CHANGED
@@ -33,6 +33,7 @@ AI 不是帮手,是倍率。它放大的不是体力,是你——你的直觉、
33
33
  - 🗂 **多项目并发**:一个 daemon 持 N 群 ↔ N session
34
34
  - 🔄 **自动 resume**:重启自动续接,session_id 落盘不丢
35
35
  - 🛡 **systemd 守护级**:WS watchdog + 单 PID + alive marker
36
+ - 📡 **HTTP 通知端点**:任意本机进程 `POST /notify` 一行 curl 把 markdown 推成卡片,info / warn / error 染色
36
37
 
37
38
  ## 怎么用
38
39
 
@@ -136,6 +137,53 @@ systemctl --user enable --now lodestar
136
137
 
137
138
  WS watchdog + alive-marker 的联手设计,意味着每次 systemd 拉起,daemon 会把**上次还在运行的 session 全部 `--resume` 自动复活**;你主动 `kill` 过的不会被吵醒。
138
139
 
140
+ ## 通知端点(Notify)
141
+
142
+ 本机任何进程都能往群里推一张卡片 —— 不走 SDK、不走鉴权,daemon 启动时
143
+ 顺带跑一个 loopback HTTP listener,默认绑 `127.0.0.1:9876`。
144
+
145
+ ```bash
146
+ curl -fsS -X POST http://127.0.0.1:9876/notify \
147
+ -H 'content-type: application/json' \
148
+ -d '{"project":"feishu","text":"**build done** 12 files"}'
149
+ ```
150
+
151
+ 请求体:
152
+
153
+ | 字段 | 必需 | 说明 |
154
+ | --- | --- | --- |
155
+ | `project` | ✅ | 飞书群名(= session 名 = `~/` 下的项目目录名)|
156
+ | `text` | ✅ | Feishu schema-2.0 markdown:`**bold**`、`` `code` ``、`[link](url)`、`<font color='red'>…</font>`;~30 KB 上限,超限返回 502 |
157
+ | `title` | | 卡片 header 标题,默认等于 `project` |
158
+ | `level` | | `info`(默认,蓝)/ `warn`(黄)/ `error`(红)|
159
+
160
+ 响应:`200 {ok, chat_id, message_id}` / `400` 参数错 / `404` 群没绑定过 / `502` 飞书 API 拒收。
161
+
162
+ > ⚠️ 群必须**至少有一条消息**触达过 daemon(WS 收到过),否则 `chatIdForSession` 查不到绑定,返回 404。新建群第一次发消息后即可用。
163
+
164
+ 可选配置(`~/.config/lodestar/config.toml`):
165
+
166
+ ```toml
167
+ [notify]
168
+ bind = "127.0.0.1" # 默认 loopback;改 0.0.0.0 必须自己加前置鉴权
169
+ port = 9876
170
+ ```
171
+
172
+ cron / systemd hook 的常见用法:
173
+
174
+ ```cron
175
+ 0 3 * * * /usr/local/bin/backup.sh \
176
+ && curl -fsS -X POST http://127.0.0.1:9876/notify -H 'content-type: application/json' \
177
+ -d '{"project":"ops","text":"✅ nightly backup OK"}' \
178
+ || curl -fsS -X POST http://127.0.0.1:9876/notify -H 'content-type: application/json' \
179
+ -d '{"project":"ops","level":"error","text":"❌ nightly backup FAILED"}'
180
+ ```
181
+
182
+ > 想让 Claude Code 自动调这个 endpoint(说一句"build 完通知我"就自己推),
183
+ > 建议你自己写一个 skill —— 在 `~/.claude/skills/feishu-notify/SKILL.md`
184
+ > 放一个 frontmatter + 触发关键词,把上面这段 curl 的 shape 抄进去即可。
185
+ > 项目本身不附带 skill 文件,要不要装、装成什么样,完全交给你。
186
+
139
187
  ## 许可
140
188
 
141
189
  [MIT](LICENSE)
package/daemon.ts CHANGED
@@ -15,6 +15,7 @@ import { chmodSync, existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSy
15
15
  import { dirname } from 'node:path'
16
16
  import { Session } from './src/session'
17
17
  import * as feishu from './src/feishu'
18
+ import { startNotifyServer } from './src/notify'
18
19
  import { config } from './src/config'
19
20
  import { log } from './src/log'
20
21
  import { DEBUG_CTX_FILE, DEBUG_SOCK_FILE, PID_FILE } from './src/paths'
@@ -343,6 +344,7 @@ async function boot(): Promise<void> {
343
344
  log(`lodestar-daemon: WS started, watching ${feishu.chatNameCache.size} groups`)
344
345
 
345
346
  startDebugSocket()
347
+ startNotifyServer({ bind: config.notify.bind, port: config.notify.port })
346
348
 
347
349
  // Auto-revive sessions that were running when we last went down.
348
350
  // Runs AFTER the WS is up so any 🔁 revive message lands in the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leviyuan/lodestar",
3
- "version": "0.2.7",
3
+ "version": "0.2.9",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
package/src/cardkit.ts CHANGED
@@ -81,7 +81,12 @@ async function call(method: string, path: string, body?: object): Promise<any> {
81
81
  }
82
82
 
83
83
  function isStreamingClosed(e: unknown): boolean {
84
- return typeof e === 'object' && e !== null && (e as any).code === 300309
84
+ if (typeof e !== 'object' || e === null) return false
85
+ const code = (e as any).code
86
+ // 300309 "streaming mode is closed" — TTL already fired before our write.
87
+ // 200850 "card streaming timeout" — TTL fired exactly during our write.
88
+ // Both mean the streaming session is gone and a reopen will unstick the card.
89
+ return code === 300309 || code === 200850
85
90
  }
86
91
 
87
92
  /** Reopen streaming_mode on a card that Feishu auto-closed after its
@@ -98,11 +103,11 @@ async function reopenStreaming(cardId: string): Promise<void> {
98
103
  }
99
104
 
100
105
  /** Run `op` inside the per-card queue. If it fails with code=300309
101
- * (Feishu auto-closed streaming after the 10-minute TTL), reopen
102
- * streaming inline and retry `op` exactly once. Anything else — non-
103
- * 300309 failure, reopen failure, retry failure — is logged and
104
- * swallowed, matching the fire-and-forget contract every cardkit op
105
- * already has at the call sites. */
106
+ * or 200850 (Feishu auto-closed / timed-out streaming after the 10-
107
+ * minute TTL), reopen streaming inline and retry `op` exactly once.
108
+ * Anything else — other failure, reopen failure, retry failure — is
109
+ * logged and swallowed, matching the fire-and-forget contract every
110
+ * cardkit op already has at the call sites. */
106
111
  async function withReopenOnStreamingClosed(
107
112
  cardId: string,
108
113
  label: string,
@@ -118,7 +123,7 @@ async function withReopenOnStreamingClosed(
118
123
  if (onFailure) onFailure()
119
124
  return
120
125
  }
121
- log(`cardkit ${label} ${cardId}: streaming closed (300309) — reopening`)
126
+ log(`cardkit ${label} ${cardId}: streaming closed (code=${(e as any).code}) — reopening`)
122
127
  }
123
128
  try {
124
129
  await reopenStreaming(cardId)
@@ -0,0 +1,352 @@
1
+ /**
2
+ * Console / menu / settings cards — every non-turn-card Feishu surface
3
+ * the daemon paints. Companion file to turn.ts; both re-exported from
4
+ * src/cards.ts.
5
+ */
6
+
7
+ import type { SysInfo } from '../sysinfo'
8
+ import type { UsageSnapshot } from '../usage'
9
+ import { ELEMENTS } from './elements'
10
+
11
+ interface ConsoleOpts {
12
+ sessionName: string
13
+ status: 'idle' | 'working' | 'awaiting_permission' | 'starting' | 'stopped'
14
+ model?: string
15
+ effort?: string
16
+ /** ms since this ClaudeProcess spawned — formatted to "1h 23m" inside. */
17
+ uptimeMs?: number
18
+ /** All sessions currently running Claude across every Feishu group
19
+ * this daemon owns. Each entry is a sibling project. Empty/undefined
20
+ * → omit the section. The session matching this card's chat is
21
+ * flagged `isCurrent` so the row can be marked. */
22
+ peers?: Array<{
23
+ name: string
24
+ isCurrent: boolean
25
+ status: 'idle' | 'working' | 'awaiting_permission' | 'starting' | 'stopped'
26
+ uptimeMs?: number
27
+ }>
28
+ /** Subscription usage snapshot from ccusage. When `installed: false`
29
+ * the row renders an install hint; otherwise we surface the current
30
+ * 5h billing block + this week's aggregate. Undefined → omit row. */
31
+ usage?: UsageSnapshot
32
+ /** Current context-window occupancy estimate (input + cache tokens of
33
+ * the last assistant message). 0 if no turn has completed yet. */
34
+ contextTokens?: number
35
+ /** Window upper bound. `null` / undefined → unknown (e.g. spawn happened
36
+ * but no `result` has landed yet); renderer omits the `/ limit (pct%)`
37
+ * suffix instead of fabricating a default. */
38
+ contextLimit?: number | null
39
+ cumStats?: { tokens: number; costUsd: number; turns: number }
40
+ lastTurn?: { tokens: number; costUsd: number; durationMs: number }
41
+ sessionId?: string | null
42
+ /** Host snapshot: CPU 负载、内存、根/家目录磁盘、cc-* systemd 服务。
43
+ * undefined → 略过整个 host 段;数据自身字段缺失 (cpu/mem 为 null)
44
+ * 时单行渲染 `_n/a_`,不假数据。 */
45
+ sysinfo?: SysInfo
46
+ }
47
+
48
+ function fmtBytes(n: number): string {
49
+ if (n < 1024) return `${n}B`
50
+ if (n < 1024 * 1024) return `${(n / 1024).toFixed(0)}K`
51
+ if (n < 1024 * 1024 * 1024) return `${(n / (1024 * 1024)).toFixed(0)}M`
52
+ const gb = n / (1024 * 1024 * 1024)
53
+ return gb < 10 ? `${gb.toFixed(1)}G` : `${gb.toFixed(0)}G`
54
+ }
55
+
56
+ /** Format token counts as a compact human-readable string: 1,234 → 1.2K. */
57
+ function fmtTokens(n: number): string {
58
+ if (!n) return '0'
59
+ if (n < 1000) return String(n)
60
+ if (n < 1_000_000) return (n / 1000).toFixed(n < 10_000 ? 1 : 0).replace(/\.0$/, '') + 'K'
61
+ return (n / 1_000_000).toFixed(2).replace(/\.?0+$/, '') + 'M'
62
+ }
63
+
64
+ function fmtCost(c: number): string {
65
+ if (c < 0.01) return `$${c.toFixed(4)}`
66
+ return `$${c.toFixed(2)}`
67
+ }
68
+
69
+ function fmtDurationMs(ms: number): string {
70
+ if (ms < 1000) return `${ms}ms`
71
+ if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`
72
+ const m = Math.floor(ms / 60_000)
73
+ const s = Math.round((ms % 60_000) / 1000)
74
+ return `${m}m ${s}s`
75
+ }
76
+
77
+ function fmtUptime(ms: number): string {
78
+ if (ms < 60_000) return `${Math.round(ms / 1000)}s`
79
+ const totalMin = Math.floor(ms / 60_000)
80
+ if (totalMin < 60) return `${totalMin}m`
81
+ const h = Math.floor(totalMin / 60)
82
+ const m = totalMin % 60
83
+ if (h < 24) return `${h}h ${m}m`
84
+ const d = Math.floor(h / 24)
85
+ return `${d}d ${h % 24}h`
86
+ }
87
+
88
+ /** Human-readable "time until" — null/past dates collapse to '已重置'. */
89
+ function fmtResetIn(date: Date | null): string {
90
+ if (!date) return '?'
91
+ const ms = date.getTime() - Date.now()
92
+ if (ms <= 0) return '已重置'
93
+ if (ms < 60 * 60 * 1000) return `${Math.max(1, Math.round(ms / 60_000))}m`
94
+ if (ms < 24 * 60 * 60 * 1000) return `${Math.round(ms / (60 * 60 * 1000))}h`
95
+ return `${Math.round(ms / (24 * 60 * 60 * 1000))}d`
96
+ }
97
+
98
+ /** Human-readable "time since" — clamps sub-minute values to "刚刚". */
99
+ function fmtAgo(timestamp: number): string {
100
+ const ms = Date.now() - timestamp
101
+ if (ms < 60_000) return '刚刚'
102
+ if (ms < 60 * 60 * 1000) return `${Math.round(ms / 60_000)}m 前`
103
+ if (ms < 24 * 60 * 60 * 1000) return `${Math.round(ms / (60 * 60 * 1000))}h 前`
104
+ return `${Math.round(ms / (24 * 60 * 60 * 1000))}d 前`
105
+ }
106
+
107
+ const PEER_STATUS_EMOJI: Record<string, string> = {
108
+ idle: '🟢', working: '⚙️', awaiting_permission: '🔐',
109
+ starting: '🚀', stopped: '⚪',
110
+ }
111
+
112
+ /** Render the subscription-usage section of the console card. Pulled out
113
+ * of `consoleCard` so the caller can patch it in after the initial card
114
+ * is on screen (网络往返可能慢于第一次 paint;先占位、回包后替换)。
115
+ *
116
+ * 数据源是 Anthropic 官方 OAuth Usage API (见 src/usage.ts)。
117
+ * 百分比是真实 utilization,失败态按 state 区分显示具体原因。
118
+ *
119
+ * `usage === undefined` → 初始 loading 占位。
120
+ */
121
+ export function consoleUsageContent(
122
+ usage: UsageSnapshot | undefined,
123
+ ): string {
124
+ if (usage === undefined) return '**📊 订阅额度** _加载中…_'
125
+ switch (usage.state) {
126
+ case 'no_credentials':
127
+ return '**📊 订阅额度** 未找到 OAuth 凭据 (`~/.claude/.credentials.json`)'
128
+ case 'auth_failed':
129
+ return '**📊 订阅额度** Token 已过期且刷新失败 — 重新 `claude auth login`'
130
+ case 'rate_limited':
131
+ return '**📊 订阅额度** API 429 限流,稍后重试'
132
+ case 'network':
133
+ return `**📊 订阅额度** 拉取失败${usage.reason ? ' — `' + usage.reason + '`' : ''}`
134
+ }
135
+ // state === 'ok' —— stale 时 head 加 "缓存 Xm 前",重置时间加 `~`
136
+ // 前缀,沿用 omchud HUD 的 stale 标记约定。
137
+ const staleNote = usage.stale ? ` _· 缓存 ${fmtAgo(usage.fetchedAt)}_` : ''
138
+ const resetPrefix = usage.stale ? '~' : ''
139
+ const head = usage.subscriptionType
140
+ ? `**📊 订阅额度** · ${usage.subscriptionType}${staleNote}`
141
+ : `**📊 订阅额度**${staleNote}`
142
+ const lines: string[] = [head]
143
+ if (usage.fiveHour) {
144
+ const parts = [`${Math.round(usage.fiveHour.percent)}%`]
145
+ if (usage.fiveHour.resetsAt) parts.push(`重置 ${resetPrefix}${fmtResetIn(usage.fiveHour.resetsAt)}`)
146
+ lines.push(` · 5h ${parts.join(' · ')}`)
147
+ }
148
+ if (usage.weekly) {
149
+ const parts = [`${Math.round(usage.weekly.percent)}%`]
150
+ if (usage.weekly.resetsAt) parts.push(`重置 ${resetPrefix}${fmtResetIn(usage.weekly.resetsAt)}`)
151
+ lines.push(` · 7d ${parts.join(' · ')}`)
152
+ }
153
+ return lines.length === 1 ? '**📊 订阅额度** _无数据_' : lines.join('\n')
154
+ }
155
+
156
+ const SERVICE_STATUS_EMOJI: Record<string, string> = {
157
+ active: '🟢', activating: '🚀', reloading: '🔄',
158
+ inactive: '⚪', deactivating: '🟡', failed: '❌',
159
+ }
160
+
161
+ /** Host snapshot lines: 1 line for CPU+mem, 1 per disk, then services list.
162
+ * 跟 peers 同样的缩进风格 (` · ` 开头),保持视觉一致。 */
163
+ function hostLines(sysinfo: SysInfo): string[] {
164
+ const out: string[] = []
165
+ const head: string[] = []
166
+ if (sysinfo.cpu) {
167
+ const { cores, load1, load5, load15 } = sysinfo.cpu
168
+ head.push(`load ${load1.toFixed(2)} / ${load5.toFixed(2)} / ${load15.toFixed(2)} (${cores}核)`)
169
+ }
170
+ if (sysinfo.mem) {
171
+ head.push(`mem ${sysinfo.mem.percent}% (${fmtBytes(sysinfo.mem.usedBytes)}/${fmtBytes(sysinfo.mem.totalBytes)})`)
172
+ }
173
+ if (head.length > 0) out.push(`**🖥 主机** ${head.join(' · ')}`)
174
+ else out.push('**🖥 主机** _n/a_')
175
+
176
+ if (sysinfo.disks.length > 0) {
177
+ const parts = sysinfo.disks.map(d =>
178
+ `\`${d.label}\` ${d.percent}% (${fmtBytes(d.usedBytes)}/${fmtBytes(d.totalBytes)})`,
179
+ )
180
+ out.push(`**💽 磁盘** ${parts.join(' · ')}`)
181
+ }
182
+
183
+ if (sysinfo.servicesError) {
184
+ out.push(`**⚙️ 服务** cc-* _${sysinfo.servicesError}_`)
185
+ } else if (sysinfo.services.length === 0) {
186
+ out.push('**⚙️ 服务** cc-* _无_')
187
+ } else {
188
+ out.push(`**⚙️ 服务** cc-* (${sysinfo.services.length})`)
189
+ for (const s of sysinfo.services) {
190
+ const dot = SERVICE_STATUS_EMOJI[s.active] ?? '·'
191
+ // 三件套: 状态 (active/inactive/failed) · 最近活跃 (上次进入
192
+ // active 距今多久) · 持续时间 (当前状态持续多久)。
193
+ // - active: 最近活跃 == 持续时间 (同一时刻),合并成"已运行 5m"
194
+ // - inactive/failed: 两者不同,分开显示"上次活跃 3d前 · 已停 1h"
195
+ // - activating/deactivating: 只显示当前状态持续时间
196
+ // - 从未跑过的 inactive: 只标"从未启动"
197
+ const lastActive = s.lastActiveAgoSec
198
+ const stateAge = s.stateAgoSec
199
+ const parts: string[] = [s.active]
200
+ if (s.active === 'active') {
201
+ if (stateAge != null) parts.push(`已运行 ${fmtUptime(stateAge * 1000)}`)
202
+ } else if (s.active === 'inactive' || s.active === 'failed') {
203
+ if (lastActive != null) {
204
+ parts.push(`上次活跃 ${fmtUptime(lastActive * 1000)}前`)
205
+ } else {
206
+ parts.push('从未启动')
207
+ }
208
+ if (stateAge != null) {
209
+ const verb = s.active === 'failed' ? '已挂' : '已停'
210
+ parts.push(`${verb} ${fmtUptime(stateAge * 1000)}`)
211
+ }
212
+ } else {
213
+ // activating / deactivating / reloading
214
+ if (stateAge != null) parts.push(`已 ${fmtUptime(stateAge * 1000)}`)
215
+ }
216
+ out.push(` · ${dot} \`${s.name}\` · ${parts.join(' · ')}`)
217
+ }
218
+ }
219
+ return out
220
+ }
221
+
222
+ export function consoleCard(opts: ConsoleOpts): object {
223
+ const {
224
+ sessionName, status, model, effort, uptimeMs, peers, usage,
225
+ contextTokens, contextLimit, cumStats, lastTurn, sessionId, sysinfo,
226
+ } = opts
227
+ const statusEmoji = {
228
+ idle: '🟢 闲', working: '⚙️ 工作中', awaiting_permission: '🔐 等审批',
229
+ starting: '🚀 启动中', stopped: '⚪ 未运行',
230
+ }[status]
231
+
232
+ const modelLine = model ? `${model}${effort ? `/${effort}` : ''}` : null
233
+ const headerLine = [statusEmoji, modelLine].filter(Boolean).join(' · ')
234
+
235
+ // Build the metric lines that make this panel useful. Each is "label
236
+ // <tab> value" rendered as plain markdown — keeps it readable inside
237
+ // the small Feishu card area without competing with the button row.
238
+ const lines: string[] = [headerLine]
239
+
240
+ if (peers && peers.length > 0) {
241
+ lines.push(`**🗂 活跃项目** (${peers.length})`)
242
+ for (const p of peers) {
243
+ const dot = PEER_STATUS_EMOJI[p.status] ?? '·'
244
+ const up = p.uptimeMs != null && p.uptimeMs > 0 ? ` · ${fmtUptime(p.uptimeMs)}` : ''
245
+ const mark = p.isCurrent ? ' ← 当前' : ''
246
+ lines.push(` · ${dot} \`${p.name}\`${up}${mark}`)
247
+ }
248
+ }
249
+ if (contextTokens != null && contextTokens > 0) {
250
+ // Show `/ limit (pct%)` only when we actually know the window —
251
+ // `contextLimit` is populated from the SDK's modelUsage on first
252
+ // `result`. Pre-result panels (fresh spawn / kill+hi / clear+hi)
253
+ // render token count alone rather than fabricating a 1M or 200K
254
+ // default that may not match the running model.
255
+ if (contextLimit != null && contextLimit > 0) {
256
+ const pct = Math.round((contextTokens / contextLimit) * 100)
257
+ lines.push(`**📦 上下文** ${fmtTokens(contextTokens)} / ${fmtTokens(contextLimit)} (${pct}%)`)
258
+ } else {
259
+ lines.push(`**📦 上下文** ${fmtTokens(contextTokens)} _窗口待 result 上报_`)
260
+ }
261
+ }
262
+ void uptimeMs // session-level uptime is already shown per-project in
263
+ // the 活跃项目 list above (peers[].uptimeMs); the dedicated row would
264
+ // duplicate it for the current session.
265
+ if (cumStats && (cumStats.tokens > 0 || cumStats.costUsd > 0 || cumStats.turns > 0)) {
266
+ lines.push(`**💬 累计** ${fmtTokens(cumStats.tokens)} tokens · ${fmtCost(cumStats.costUsd)} · ${cumStats.turns} turn${cumStats.turns === 1 ? '' : 's'}`)
267
+ }
268
+ if (lastTurn) {
269
+ lines.push(`**🔄 上一轮** +${fmtTokens(lastTurn.tokens)} · ${fmtCost(lastTurn.costUsd)} · ${fmtDurationMs(lastTurn.durationMs)}`)
270
+ }
271
+ if (sessionId) {
272
+ lines.push(`**🆔 session** \`${sessionId.slice(0, 8)}…\``)
273
+ }
274
+ if (sysinfo) {
275
+ lines.push(...hostLines(sysinfo))
276
+ }
277
+
278
+ const template = status === 'working' ? 'blue'
279
+ : status === 'awaiting_permission' ? 'orange'
280
+ : status === 'stopped' ? 'grey'
281
+ : 'green'
282
+
283
+ return {
284
+ schema: '2.0',
285
+ config: { update_multi: true },
286
+ header: {
287
+ title: { tag: 'plain_text', content: `🌟 Lodestar · ${sessionName}` },
288
+ template,
289
+ },
290
+ body: {
291
+ elements: [
292
+ { tag: 'markdown', content: lines.join('\n') },
293
+ // Separate element so showConsole() can replace it after the
294
+ // ccusage fetch completes — initial paint goes out immediately
295
+ // with `_加载中…_`, then this row swaps to real data.
296
+ {
297
+ tag: 'markdown',
298
+ element_id: ELEMENTS.consoleUsage,
299
+ content: consoleUsageContent(usage),
300
+ },
301
+ ],
302
+ },
303
+ }
304
+ }
305
+
306
+ interface MenuOpts {
307
+ question: string
308
+ options: string[]
309
+ requestId: string
310
+ }
311
+
312
+ export function menuCard(opts: MenuOpts): object {
313
+ const { question, options, requestId } = opts
314
+ return {
315
+ schema: '2.0',
316
+ config: { update_multi: true },
317
+ header: {
318
+ title: { tag: 'plain_text', content: '📋 等待选择' },
319
+ template: 'turquoise',
320
+ },
321
+ body: {
322
+ elements: [
323
+ { tag: 'markdown', content: question || '_请选择一项:_' },
324
+ ...options.map((opt, i) => ({
325
+ tag: 'button',
326
+ text: { tag: 'plain_text', content: opt },
327
+ type: 'default',
328
+ behaviors: [{ type: 'callback', value: { kind: 'menu', request_id: requestId, choice: i } }],
329
+ })),
330
+ ],
331
+ },
332
+ }
333
+ }
334
+
335
+ /** Settings patch applied when a turn finishes — flips streaming off
336
+ * and updates the chat-list preview with `⏱ duration · NK tokens`
337
+ * (or just the suffix if interrupted before a result event). */
338
+ export function streamingOffSettings(opts: {
339
+ durationSec: string
340
+ tokens?: number
341
+ suffix?: string
342
+ }): object {
343
+ const parts: string[] = []
344
+ parts.push(opts.suffix ?? '✅')
345
+ parts.push(`⏱ ${opts.durationSec}s`)
346
+ if (opts.tokens != null && opts.tokens > 0) {
347
+ parts.push(`${fmtTokens(opts.tokens)} tokens`)
348
+ }
349
+ return {
350
+ config: { streaming_mode: false, summary: { content: parts.join(' · ') } },
351
+ }
352
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Element-id convention (must be unique within a card):
3
+ * user_input — the collapsible "你说" panel
4
+ * thinking — the de-emphasized thinking stream
5
+ * tool_<i> — one collapsible per tool call, indexed from 0
6
+ * assistant — the main streaming assistant answer
7
+ * footer — runtime footer (timing / status)
8
+ */
9
+ export const ELEMENTS = {
10
+ thinking: 'thinking',
11
+ footer: 'footer',
12
+ tool: (i: number) => `tool_${i}`,
13
+ /** Assistant text is segmented: every tool call closes the running segment
14
+ * and the next assistant chunk opens a new one, so element order in the
15
+ * card matches Claude's emission order. */
16
+ assistant: (i: number) => `assistant_${i}`,
17
+ /** Console (hi) card — the subscription-usage row is rendered as its
18
+ * own element so we can replace it after the initial card lands,
19
+ * decoupling the slow ccusage fetch from the rest of the panel's
20
+ * synchronous data. */
21
+ consoleUsage: 'console_usage',
22
+ } as const