@leviyuan/lodestar 0.2.9 → 0.3.0
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 +74 -41
- package/dist/lodestar-setup.js +12 -0
- package/dist/lodestar.js +147 -0
- package/package.json +15 -6
- package/scripts/postinstall.cjs +97 -0
- package/daemon.ts +0 -355
- package/src/cardkit.ts +0 -349
- package/src/cards/console.ts +0 -352
- package/src/cards/elements.ts +0 -22
- package/src/cards/turn.ts +0 -530
- package/src/cards.ts +0 -32
- package/src/claude-process.ts +0 -417
- package/src/config.ts +0 -98
- package/src/feishu.ts +0 -498
- package/src/instructions.ts +0 -13
- package/src/log.ts +0 -11
- package/src/notify.ts +0 -132
- package/src/paths.ts +0 -57
- package/src/session-ask.ts +0 -165
- package/src/session-permission.ts +0 -136
- package/src/session-tools.ts +0 -233
- package/src/session-types.ts +0 -91
- package/src/session.ts +0 -1137
- package/src/sysinfo.ts +0 -273
- package/src/usage.ts +0 -327
package/src/cardkit.ts
DELETED
|
@@ -1,349 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Feishu Card Kit v1 wrapper.
|
|
3
|
-
*
|
|
4
|
-
* Endpoints used (base = https://open.feishu.cn/open-apis/cardkit/v1):
|
|
5
|
-
* POST /cards/id_convert message_id → card_id
|
|
6
|
-
* POST /cards create a card entity
|
|
7
|
-
* PUT /cards/:card_id/elements/:element_id/content stream text (typewriter)
|
|
8
|
-
* POST /cards/:card_id/elements add element
|
|
9
|
-
* PUT /cards/:card_id/elements/:element_id replace element
|
|
10
|
-
* DELETE /cards/:card_id/elements/:element_id remove element
|
|
11
|
-
* PATCH /cards/:card_id/settings toggle streaming_mode etc.
|
|
12
|
-
*
|
|
13
|
-
* Per-card invariants enforced here:
|
|
14
|
-
* - `sequence` is monotonically increasing per card_id
|
|
15
|
-
* - all writes for a card are serialized through a Promise queue
|
|
16
|
-
* - text-streaming PUTs are batched on a 120ms / 32-char heuristic to
|
|
17
|
-
* stay well under cardkit's per-card rate ceiling
|
|
18
|
-
*/
|
|
19
|
-
|
|
20
|
-
import { getTenantToken } from './feishu'
|
|
21
|
-
import { log } from './log'
|
|
22
|
-
|
|
23
|
-
const BASE = 'https://open.feishu.cn/open-apis/cardkit/v1'
|
|
24
|
-
|
|
25
|
-
const FLUSH_INTERVAL_MS = 120
|
|
26
|
-
const FLUSH_MIN_DELTA = 32
|
|
27
|
-
|
|
28
|
-
interface CardState {
|
|
29
|
-
sequence: number
|
|
30
|
-
queue: Promise<void>
|
|
31
|
-
buffer: Map<string, string> // element_id → latest full text
|
|
32
|
-
lastSent: Map<string, string> // element_id → text last actually PUT
|
|
33
|
-
flushTimer: ReturnType<typeof setTimeout> | null
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
const cards = new Map<string, CardState>()
|
|
37
|
-
|
|
38
|
-
interface SummaryState {
|
|
39
|
-
latest: string
|
|
40
|
-
lastSent: string
|
|
41
|
-
timer: ReturnType<typeof setTimeout> | null
|
|
42
|
-
}
|
|
43
|
-
const summaryStates = new Map<string, SummaryState>()
|
|
44
|
-
const SUMMARY_FLUSH_MS = 1500
|
|
45
|
-
|
|
46
|
-
function state(cardId: string): CardState {
|
|
47
|
-
let s = cards.get(cardId)
|
|
48
|
-
if (!s) {
|
|
49
|
-
s = {
|
|
50
|
-
sequence: 0,
|
|
51
|
-
queue: Promise.resolve(),
|
|
52
|
-
buffer: new Map(),
|
|
53
|
-
lastSent: new Map(),
|
|
54
|
-
flushTimer: null,
|
|
55
|
-
}
|
|
56
|
-
cards.set(cardId, s)
|
|
57
|
-
}
|
|
58
|
-
return s
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
function nextSeq(cardId: string): number {
|
|
62
|
-
const s = state(cardId)
|
|
63
|
-
s.sequence += 1
|
|
64
|
-
return s.sequence
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
async function call(method: string, path: string, body?: object): Promise<any> {
|
|
68
|
-
const token = await getTenantToken()
|
|
69
|
-
const res = await fetch(`${BASE}${path}`, {
|
|
70
|
-
method,
|
|
71
|
-
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
|
72
|
-
...(body ? { body: JSON.stringify(body) } : {}),
|
|
73
|
-
})
|
|
74
|
-
const json = await res.json() as any
|
|
75
|
-
if (json?.code && json.code !== 0) {
|
|
76
|
-
const e = new Error(`cardkit ${method} ${path}: code=${json.code} msg=${json.msg}`) as Error & { code: number }
|
|
77
|
-
e.code = json.code
|
|
78
|
-
throw e
|
|
79
|
-
}
|
|
80
|
-
return json?.data
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
function isStreamingClosed(e: unknown): boolean {
|
|
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
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
/** Reopen streaming_mode on a card that Feishu auto-closed after its
|
|
93
|
-
* 10-minute streaming TTL (no keepalive, no idle reset — the timer
|
|
94
|
-
* starts when streaming is opened and fires regardless of activity).
|
|
95
|
-
* Called from inside the per-card queue's catch path, so it allocates
|
|
96
|
-
* its own sequence and runs inline without re-enqueueing. */
|
|
97
|
-
async function reopenStreaming(cardId: string): Promise<void> {
|
|
98
|
-
const seq = nextSeq(cardId)
|
|
99
|
-
await call('PATCH', `/cards/${cardId}/settings`, {
|
|
100
|
-
settings: JSON.stringify({ config: { streaming_mode: true } }),
|
|
101
|
-
sequence: seq,
|
|
102
|
-
})
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
/** Run `op` inside the per-card queue. If it fails with code=300309
|
|
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. */
|
|
111
|
-
async function withReopenOnStreamingClosed(
|
|
112
|
-
cardId: string,
|
|
113
|
-
label: string,
|
|
114
|
-
op: () => Promise<void>,
|
|
115
|
-
onFailure?: () => void,
|
|
116
|
-
): Promise<void> {
|
|
117
|
-
try {
|
|
118
|
-
await op()
|
|
119
|
-
return
|
|
120
|
-
} catch (e) {
|
|
121
|
-
if (!isStreamingClosed(e)) {
|
|
122
|
-
log(`cardkit ${label} ${cardId}: ${e}`)
|
|
123
|
-
if (onFailure) onFailure()
|
|
124
|
-
return
|
|
125
|
-
}
|
|
126
|
-
log(`cardkit ${label} ${cardId}: streaming closed (code=${(e as any).code}) — reopening`)
|
|
127
|
-
}
|
|
128
|
-
try {
|
|
129
|
-
await reopenStreaming(cardId)
|
|
130
|
-
} catch (re) {
|
|
131
|
-
log(`cardkit STREAMING_REOPEN_FAILED ${cardId}: ${re}`)
|
|
132
|
-
if (onFailure) onFailure()
|
|
133
|
-
return
|
|
134
|
-
}
|
|
135
|
-
try {
|
|
136
|
-
await op()
|
|
137
|
-
} catch (e2) {
|
|
138
|
-
log(`cardkit ${label} ${cardId} retry-after-reopen: ${e2}`)
|
|
139
|
-
if (onFailure) onFailure()
|
|
140
|
-
}
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
/** Convert a sent interactive message into a card entity. */
|
|
144
|
-
export async function convertMessageToCard(messageId: string): Promise<string> {
|
|
145
|
-
const data = await call('POST', '/cards/id_convert', { message_id: messageId })
|
|
146
|
-
return data.card_id
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
/** Create a card entity from raw schema-2.0 card JSON. */
|
|
150
|
-
export async function createCardEntity(card: object): Promise<string> {
|
|
151
|
-
const data = await call('POST', '/cards', {
|
|
152
|
-
type: 'card_json',
|
|
153
|
-
data: JSON.stringify(card),
|
|
154
|
-
})
|
|
155
|
-
return data.card_id
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
/** PUT element content (full text) — triggers typewriter on prefix-match.
|
|
159
|
-
*
|
|
160
|
-
* NOTE: CardKit rejects empty-string content with code 99992402 ("field
|
|
161
|
-
* validation failed"); we drop empty/whitespace-only writes here so callers
|
|
162
|
-
* can stream naively without per-call empty checks. */
|
|
163
|
-
export function streamText(cardId: string, elementId: string, content: string): Promise<void> {
|
|
164
|
-
if (!content || !content.trim()) return Promise.resolve()
|
|
165
|
-
const s = state(cardId)
|
|
166
|
-
s.queue = s.queue.then(() => withReopenOnStreamingClosed(
|
|
167
|
-
cardId,
|
|
168
|
-
`streamText ${elementId}`,
|
|
169
|
-
async () => {
|
|
170
|
-
const seq = nextSeq(cardId)
|
|
171
|
-
await call('PUT', `/cards/${cardId}/elements/${elementId}/content`, {
|
|
172
|
-
content, sequence: seq,
|
|
173
|
-
})
|
|
174
|
-
s.lastSent.set(elementId, content)
|
|
175
|
-
},
|
|
176
|
-
))
|
|
177
|
-
return s.queue
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
/** Throttled streaming: buffer + auto-flush every FLUSH_INTERVAL_MS or
|
|
181
|
-
* when the buffered delta crosses FLUSH_MIN_DELTA characters. */
|
|
182
|
-
export function streamTextThrottled(cardId: string, elementId: string, fullContent: string): void {
|
|
183
|
-
if (!fullContent || !fullContent.trim()) return
|
|
184
|
-
const s = state(cardId)
|
|
185
|
-
s.buffer.set(elementId, fullContent)
|
|
186
|
-
|
|
187
|
-
const last = s.lastSent.get(elementId) ?? ''
|
|
188
|
-
const delta = fullContent.length - last.length
|
|
189
|
-
if (delta >= FLUSH_MIN_DELTA) {
|
|
190
|
-
flush(cardId).catch(e => log(`cardkit flush(min-delta) ${cardId}: ${e}`))
|
|
191
|
-
return
|
|
192
|
-
}
|
|
193
|
-
if (!s.flushTimer) {
|
|
194
|
-
s.flushTimer = setTimeout(() => {
|
|
195
|
-
flush(cardId).catch(e => log(`cardkit flush(timer) ${cardId}: ${e}`))
|
|
196
|
-
}, FLUSH_INTERVAL_MS)
|
|
197
|
-
}
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
/** Force an immediate flush of the buffered streams for a card. */
|
|
201
|
-
export async function flush(cardId: string): Promise<void> {
|
|
202
|
-
const s = cards.get(cardId)
|
|
203
|
-
if (!s) return
|
|
204
|
-
if (s.flushTimer) { clearTimeout(s.flushTimer); s.flushTimer = null }
|
|
205
|
-
const pending = [...s.buffer.entries()]
|
|
206
|
-
s.buffer.clear()
|
|
207
|
-
for (const [eid, text] of pending) {
|
|
208
|
-
if (s.lastSent.get(eid) === text) continue
|
|
209
|
-
await streamText(cardId, eid, text)
|
|
210
|
-
}
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
/** Add a new element to the card body or relative to a sibling.
|
|
214
|
-
*
|
|
215
|
-
* `onFailure` fires asynchronously (after promise queue settles) if the
|
|
216
|
-
* element was NOT created — either the first attempt failed with a non-
|
|
217
|
-
* 300309 error, or the retry-after-reopen also failed. Use it to invalidate
|
|
218
|
-
* any daemon-side reference to the element you tried to add (e.g. a segment
|
|
219
|
-
* id), so subsequent writes don't keep PUTting content to a phantom element
|
|
220
|
-
* that Feishu will silently reject. Default (no callback) preserves the
|
|
221
|
-
* legacy fire-and-forget swallow behavior. */
|
|
222
|
-
export function addElement(
|
|
223
|
-
cardId: string,
|
|
224
|
-
element: object,
|
|
225
|
-
opts: { type?: 'append' | 'insert_before' | 'insert_after'; targetElementId?: string } = {},
|
|
226
|
-
onFailure?: () => void,
|
|
227
|
-
): Promise<void> {
|
|
228
|
-
const s = state(cardId)
|
|
229
|
-
s.queue = s.queue.then(() => withReopenOnStreamingClosed(
|
|
230
|
-
cardId,
|
|
231
|
-
`addElement`,
|
|
232
|
-
async () => {
|
|
233
|
-
const seq = nextSeq(cardId)
|
|
234
|
-
await call('POST', `/cards/${cardId}/elements`, {
|
|
235
|
-
type: opts.type ?? 'append',
|
|
236
|
-
...(opts.targetElementId ? { target_element_id: opts.targetElementId } : {}),
|
|
237
|
-
elements: JSON.stringify([element]),
|
|
238
|
-
sequence: seq,
|
|
239
|
-
})
|
|
240
|
-
},
|
|
241
|
-
onFailure,
|
|
242
|
-
))
|
|
243
|
-
return s.queue
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
/** Replace an entire element (used to swap a tool placeholder with its result). */
|
|
247
|
-
export function replaceElement(cardId: string, elementId: string, element: object): Promise<void> {
|
|
248
|
-
const s = state(cardId)
|
|
249
|
-
s.queue = s.queue.then(() => withReopenOnStreamingClosed(
|
|
250
|
-
cardId,
|
|
251
|
-
`replaceElement ${elementId}`,
|
|
252
|
-
async () => {
|
|
253
|
-
const seq = nextSeq(cardId)
|
|
254
|
-
await call('PUT', `/cards/${cardId}/elements/${elementId}`, {
|
|
255
|
-
element: JSON.stringify(element),
|
|
256
|
-
sequence: seq,
|
|
257
|
-
})
|
|
258
|
-
},
|
|
259
|
-
))
|
|
260
|
-
return s.queue
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
/** Delete an element by id. */
|
|
264
|
-
export function deleteElement(cardId: string, elementId: string): Promise<void> {
|
|
265
|
-
const s = state(cardId)
|
|
266
|
-
s.queue = s.queue.then(() => withReopenOnStreamingClosed(
|
|
267
|
-
cardId,
|
|
268
|
-
`deleteElement ${elementId}`,
|
|
269
|
-
async () => {
|
|
270
|
-
const seq = nextSeq(cardId)
|
|
271
|
-
await call('DELETE', `/cards/${cardId}/elements/${elementId}`, {
|
|
272
|
-
sequence: seq,
|
|
273
|
-
})
|
|
274
|
-
},
|
|
275
|
-
))
|
|
276
|
-
return s.queue
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
/** Throttled card-summary update. The summary text is what Feishu shows
|
|
280
|
-
* in the chat list as the message preview. We coalesce writes on a
|
|
281
|
-
* SUMMARY_FLUSH_MS window so streaming assistant deltas don't blow up
|
|
282
|
-
* the settings-PATCH endpoint. Whitespace is collapsed and the input
|
|
283
|
-
* is trimmed; empty content is ignored. */
|
|
284
|
-
export function patchSummaryThrottled(cardId: string, content: string): void {
|
|
285
|
-
const trimmed = (content ?? '').replace(/\s+/g, ' ').trim()
|
|
286
|
-
if (!trimmed) return
|
|
287
|
-
let s = summaryStates.get(cardId)
|
|
288
|
-
if (!s) {
|
|
289
|
-
s = { latest: trimmed, lastSent: '', timer: null }
|
|
290
|
-
summaryStates.set(cardId, s)
|
|
291
|
-
} else {
|
|
292
|
-
s.latest = trimmed
|
|
293
|
-
}
|
|
294
|
-
if (s.timer) return
|
|
295
|
-
s.timer = setTimeout(() => {
|
|
296
|
-
const st = summaryStates.get(cardId)
|
|
297
|
-
if (!st) return
|
|
298
|
-
st.timer = null
|
|
299
|
-
if (st.latest === st.lastSent) return
|
|
300
|
-
const toSend = st.latest
|
|
301
|
-
st.lastSent = toSend
|
|
302
|
-
void patchSettings(cardId, { config: { summary: { content: toSend } } })
|
|
303
|
-
}, SUMMARY_FLUSH_MS)
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
/** Cancel any pending throttled summary write. Call before emitting
|
|
307
|
-
* a terminal summary (e.g. "✅ ⏱ 12.3s · 4.2K tokens") so a stale
|
|
308
|
-
* mid-stream tail can't fire after and clobber the final preview. */
|
|
309
|
-
export function cancelSummary(cardId: string): void {
|
|
310
|
-
const s = summaryStates.get(cardId)
|
|
311
|
-
if (!s) return
|
|
312
|
-
if (s.timer) { clearTimeout(s.timer); s.timer = null }
|
|
313
|
-
summaryStates.delete(cardId)
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
/** Patch settings — used to flip streaming_mode off when a turn finishes.
|
|
317
|
-
*
|
|
318
|
-
* `nextSeq` is called inside the queued task (not at enqueue time) to
|
|
319
|
-
* match streamText/addElement/replaceElement/deleteElement above. Mixing
|
|
320
|
-
* call-time and execution-time seq allocation interleaves badly: a
|
|
321
|
-
* patchSettings enqueued right after a replaceElement would grab the
|
|
322
|
-
* smaller seq number, but the replaceElement's then-block would grab
|
|
323
|
-
* the larger one when it ran first, so the patchSettings PATCH lands
|
|
324
|
-
* with a stale seq and Feishu rejects 300317 "sequence number compare
|
|
325
|
-
* failed". Keeping all writes on execution-time allocation makes the
|
|
326
|
-
* seq order match the queue order. */
|
|
327
|
-
export function patchSettings(cardId: string, settings: object): Promise<void> {
|
|
328
|
-
const s = state(cardId)
|
|
329
|
-
s.queue = s.queue.then(async () => {
|
|
330
|
-
try {
|
|
331
|
-
const seq = nextSeq(cardId)
|
|
332
|
-
await call('PATCH', `/cards/${cardId}/settings`, {
|
|
333
|
-
settings: JSON.stringify(settings),
|
|
334
|
-
sequence: seq,
|
|
335
|
-
})
|
|
336
|
-
} catch (e) { log(`cardkit patchSettings ${cardId}: ${e}`) }
|
|
337
|
-
})
|
|
338
|
-
return s.queue
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
/** Drop in-memory bookkeeping for a finished card. */
|
|
342
|
-
export async function dispose(cardId: string): Promise<void> {
|
|
343
|
-
const s = cards.get(cardId)
|
|
344
|
-
if (!s) return
|
|
345
|
-
await flush(cardId)
|
|
346
|
-
await s.queue
|
|
347
|
-
cards.delete(cardId)
|
|
348
|
-
cancelSummary(cardId)
|
|
349
|
-
}
|
package/src/cards/console.ts
DELETED
|
@@ -1,352 +0,0 @@
|
|
|
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
|
-
}
|