@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 +48 -0
- package/daemon.ts +2 -0
- package/package.json +1 -1
- package/src/cardkit.ts +12 -7
- package/src/cards/console.ts +352 -0
- package/src/cards/elements.ts +22 -0
- package/src/cards/turn.ts +530 -0
- package/src/cards.ts +29 -795
- package/src/claude-process.ts +26 -4
- package/src/config.ts +16 -1
- package/src/feishu.ts +14 -47
- package/src/notify.ts +132 -0
- package/src/session-ask.ts +165 -0
- package/src/session-permission.ts +136 -0
- package/src/session-tools.ts +233 -0
- package/src/session-types.ts +91 -0
- package/src/session.ts +204 -655
- package/src/sysinfo.ts +273 -0
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tool-tracking helpers split out of session.ts. Free functions taking
|
|
3
|
+
* a Session; fields they touch are package-internal (no `private`
|
|
4
|
+
* modifier on the class side). Cross-file boundary lets the main
|
|
5
|
+
* session.ts stay under Claude Code's per-read token budget.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { Session } from './session'
|
|
9
|
+
import * as cardkit from './cardkit'
|
|
10
|
+
import * as cards from './cards'
|
|
11
|
+
import * as feishu from './feishu'
|
|
12
|
+
|
|
13
|
+
export function isTaskWorkflow(name: string): boolean {
|
|
14
|
+
return name.startsWith('Task') && name !== 'Task'
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function todosArray(s: Session): cards.Todo[] {
|
|
18
|
+
return [...s.currentTodos.values()]
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function addTool(s: Session, toolUseId: string, name: string, input: any): void {
|
|
22
|
+
if (!s.currentTurn) return
|
|
23
|
+
// Close current assistant segment (if any) so the tool panel renders
|
|
24
|
+
// AFTER it in card body order. Flush queues the segment's last
|
|
25
|
+
// buffered delta before the tool element is inserted.
|
|
26
|
+
if (s.currentTurn.currentAssistantSegmentId) {
|
|
27
|
+
void cardkit.flush(s.currentTurn.cardId)
|
|
28
|
+
s.currentTurn.currentAssistantSegmentId = null
|
|
29
|
+
s.currentTurn.currentAssistantText = ''
|
|
30
|
+
}
|
|
31
|
+
// Consecutive Read merger: if a Read run is already open, append to
|
|
32
|
+
// its batch and re-render the panel instead of inserting a new one.
|
|
33
|
+
// Any other tool name closes the run (handled below).
|
|
34
|
+
if (name === 'Read' && s.currentTurn.openReadBatchI !== null) {
|
|
35
|
+
const batchI = s.currentTurn.openReadBatchI
|
|
36
|
+
const batch = s.currentTurn.readBatches.get(batchI)!
|
|
37
|
+
const slot = batch.items.length
|
|
38
|
+
batch.items.push({ toolUseId, input, output: null, isError: false })
|
|
39
|
+
s.currentTurn.toolByUseId.set(toolUseId, { i: batchI, name, input, readBatchSlot: slot })
|
|
40
|
+
const el = cards.readBatchElement(batchI, batch.items)
|
|
41
|
+
void cardkit.replaceElement(s.currentTurn.cardId, cards.ELEMENTS.tool(batchI), el)
|
|
42
|
+
return
|
|
43
|
+
}
|
|
44
|
+
if (name !== 'Read') s.currentTurn.openReadBatchI = null
|
|
45
|
+
const i = s.currentTurn.toolCount++
|
|
46
|
+
if (name === 'Read') {
|
|
47
|
+
// First Read of a potential run — render the existing single-tool
|
|
48
|
+
// panel (which keeps the full file-contents dump on completion). If
|
|
49
|
+
// a second Read arrives, completeTool/addTool will switch it to
|
|
50
|
+
// `readBatchElement`.
|
|
51
|
+
s.currentTurn.openReadBatchI = i
|
|
52
|
+
s.currentTurn.readBatches.set(i, {
|
|
53
|
+
items: [{ toolUseId, input, output: null, isError: false }],
|
|
54
|
+
})
|
|
55
|
+
s.currentTurn.toolByUseId.set(toolUseId, { i, name, input, readBatchSlot: 0 })
|
|
56
|
+
const el = cards.toolCallElement(i, name, input, null, '⏳', undefined, undefined)
|
|
57
|
+
void cardkit.addElement(s.currentTurn.cardId, el, {
|
|
58
|
+
type: 'insert_before', targetElementId: cards.ELEMENTS.footer,
|
|
59
|
+
})
|
|
60
|
+
return
|
|
61
|
+
}
|
|
62
|
+
s.currentTurn.toolByUseId.set(toolUseId, { i, name, input })
|
|
63
|
+
// AskUserQuestion is a client-side tool — daemon renders the choice
|
|
64
|
+
// UI in-line and supplies the tool_result itself once the user
|
|
65
|
+
// clicks. Branch BEFORE the generic toolCallElement so we never
|
|
66
|
+
// fall through to a JSON dump or, worse, get clobbered by the
|
|
67
|
+
// permission flow (which would render 🔐 three-button buttons that
|
|
68
|
+
// don't match the actual N options).
|
|
69
|
+
if (name === 'AskUserQuestion') {
|
|
70
|
+
const questions = Array.isArray(input?.questions) ? input.questions as cards.AskQuestion[] : []
|
|
71
|
+
const startIdx = questions.length > 0 ? 0 : undefined
|
|
72
|
+
const answered = new Map<number, cards.AskAnswered>()
|
|
73
|
+
s.pendingAsks.set(toolUseId, {
|
|
74
|
+
questions,
|
|
75
|
+
i,
|
|
76
|
+
answers: {},
|
|
77
|
+
answered,
|
|
78
|
+
currentIdx: startIdx,
|
|
79
|
+
})
|
|
80
|
+
const el = cards.askUserQuestionElement(i, toolUseId, questions, '🤔', {
|
|
81
|
+
currentIdx: startIdx,
|
|
82
|
+
answered,
|
|
83
|
+
})
|
|
84
|
+
void cardkit.addElement(s.currentTurn.cardId, el, {
|
|
85
|
+
type: 'insert_before',
|
|
86
|
+
targetElementId: cards.ELEMENTS.footer,
|
|
87
|
+
})
|
|
88
|
+
// Phone push — user has to come back and answer before Claude can
|
|
89
|
+
// continue. Set summary to the question text so the lock-screen
|
|
90
|
+
// notification preview shows what the user needs to answer.
|
|
91
|
+
if (s.currentTurn.userOpenId && s.currentTurn.messageId) {
|
|
92
|
+
const turn = s.currentTurn
|
|
93
|
+
const q0 = questions[0]?.question?.trim() ?? ''
|
|
94
|
+
const truncated = q0.length > 40 ? q0.slice(0, 40) + '…' : q0
|
|
95
|
+
const summary = questions.length > 1
|
|
96
|
+
? `❓ 待回答 ${questions.length} 题${truncated ? `: ${truncated}` : ''}`
|
|
97
|
+
: truncated
|
|
98
|
+
? `❓ ${truncated}`
|
|
99
|
+
: '❓ 等你回答问题'
|
|
100
|
+
void (async () => {
|
|
101
|
+
cardkit.cancelSummary(turn.cardId)
|
|
102
|
+
await cardkit.patchSettings(turn.cardId, { config: { summary: { content: summary } } })
|
|
103
|
+
await feishu.urgentApp(turn.messageId, [turn.userOpenId])
|
|
104
|
+
})()
|
|
105
|
+
}
|
|
106
|
+
return
|
|
107
|
+
}
|
|
108
|
+
// Pending Task* panels still show the *pre-op* todo mirror so users
|
|
109
|
+
// can read the current state immediately, without waiting for the
|
|
110
|
+
// tool to return.
|
|
111
|
+
const todos = isTaskWorkflow(name) ? todosArray(s) : undefined
|
|
112
|
+
const el = cards.toolCallElement(i, name, input, null, '⏳', undefined, todos)
|
|
113
|
+
void cardkit.addElement(s.currentTurn.cardId, el, {
|
|
114
|
+
type: 'insert_before',
|
|
115
|
+
targetElementId: cards.ELEMENTS.footer,
|
|
116
|
+
})
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function completeTool(s: Session, toolUseId: string, content: any, isError: boolean): void {
|
|
120
|
+
if (!s.currentTurn) return
|
|
121
|
+
const meta = s.currentTurn.toolByUseId.get(toolUseId)
|
|
122
|
+
if (!meta) return
|
|
123
|
+
const output = typeof content === 'string'
|
|
124
|
+
? content
|
|
125
|
+
: Array.isArray(content)
|
|
126
|
+
? content.map((c: any) => c?.text ?? JSON.stringify(c)).join('\n')
|
|
127
|
+
: JSON.stringify(content)
|
|
128
|
+
// Stash on the meta — every Task* op coming after this point may
|
|
129
|
+
// need to re-render this panel with a fresher todo footer, so we
|
|
130
|
+
// can't discard the output after the first paint.
|
|
131
|
+
meta.output = output
|
|
132
|
+
meta.isError = isError
|
|
133
|
+
// AskUserQuestion already had its final panel painted by resolveAsk
|
|
134
|
+
// (✅ + the chosen option marked, others dimmed). The tool_result
|
|
135
|
+
// arriving here is just the SDK's synthesised echo — re-rendering
|
|
136
|
+
// via toolCallElement would clobber the nice option-row layout
|
|
137
|
+
// with a generic JSON dump. Bail out; the panel is done.
|
|
138
|
+
if (meta.name === 'AskUserQuestion') return
|
|
139
|
+
// Read batch path: update this row's status in the shared batch then
|
|
140
|
+
// re-render. Single-item batches keep the original full-output panel
|
|
141
|
+
// (file-contents dump); 2+ items switch to the compact `Read · N 次`
|
|
142
|
+
// listing, which overwrites whatever was last drawn at this i.
|
|
143
|
+
if (meta.name === 'Read' && meta.readBatchSlot != null) {
|
|
144
|
+
const batch = s.currentTurn.readBatches.get(meta.i)
|
|
145
|
+
if (batch) {
|
|
146
|
+
const row = batch.items[meta.readBatchSlot]
|
|
147
|
+
if (row) { row.output = output; row.isError = isError }
|
|
148
|
+
const el = batch.items.length >= 2
|
|
149
|
+
? cards.readBatchElement(meta.i, batch.items)
|
|
150
|
+
: cards.toolCallElement(meta.i, meta.name, meta.input, output, isError ? '❌' : '✅', meta.resolvedNote, undefined)
|
|
151
|
+
void cardkit.replaceElement(s.currentTurn.cardId, cards.ELEMENTS.tool(meta.i), el)
|
|
152
|
+
}
|
|
153
|
+
return
|
|
154
|
+
}
|
|
155
|
+
// Update the local todo mirror BEFORE rendering so the just-
|
|
156
|
+
// completed panel shows the new state too (e.g. a TaskCreate panel
|
|
157
|
+
// already lists the task it just created).
|
|
158
|
+
if (!isError && isTaskWorkflow(meta.name)) {
|
|
159
|
+
updateTodosFromTask(s, meta.name, meta.input, output)
|
|
160
|
+
}
|
|
161
|
+
const todos = isTaskWorkflow(meta.name) ? todosArray(s) : undefined
|
|
162
|
+
const el = cards.toolCallElement(meta.i, meta.name, meta.input, output, isError ? '❌' : '✅', meta.resolvedNote, todos)
|
|
163
|
+
void cardkit.replaceElement(s.currentTurn.cardId, cards.ELEMENTS.tool(meta.i), el)
|
|
164
|
+
// Cascade the new mirror into every prior Task* panel in this turn
|
|
165
|
+
// so any expanded panel reflects the latest state, not the snapshot
|
|
166
|
+
// captured when that op ran.
|
|
167
|
+
if (!isError && isTaskWorkflow(meta.name)) {
|
|
168
|
+
refreshOtherTaskPanels(s, toolUseId)
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Roll a single Task* op into the local mirror — best-effort. Output
|
|
173
|
+
* parsing is regex-based (the SDK returns plain text like "Task #7
|
|
174
|
+
* created successfully: …"), so unexpected variants are skipped
|
|
175
|
+
* silently rather than blowing up the panel render. */
|
|
176
|
+
export function updateTodosFromTask(s: Session, name: string, input: any, output: string): void {
|
|
177
|
+
switch (name) {
|
|
178
|
+
case 'TaskCreate': {
|
|
179
|
+
const m = output.match(/Task #(\d+) created/)
|
|
180
|
+
if (!m) return
|
|
181
|
+
const id = Number(m[1])
|
|
182
|
+
s.currentTodos.set(id, {
|
|
183
|
+
id,
|
|
184
|
+
subject: input.subject,
|
|
185
|
+
description: input.description,
|
|
186
|
+
activeForm: input.activeForm,
|
|
187
|
+
status: 'pending',
|
|
188
|
+
})
|
|
189
|
+
return
|
|
190
|
+
}
|
|
191
|
+
case 'TaskUpdate': {
|
|
192
|
+
const id = Number(input.taskId)
|
|
193
|
+
if (!Number.isFinite(id)) return
|
|
194
|
+
// status=deleted is the SDK's tombstone — drop from the mirror
|
|
195
|
+
// so the readout doesn't carry it forever. Server still keeps
|
|
196
|
+
// it; the mirror is just for the panel footer.
|
|
197
|
+
if (input.status === 'deleted') { s.currentTodos.delete(id); return }
|
|
198
|
+
const cur = s.currentTodos.get(id) ?? { id, status: 'pending' as const }
|
|
199
|
+
if (input.status) cur.status = input.status
|
|
200
|
+
if (input.subject) cur.subject = input.subject
|
|
201
|
+
if (input.description) cur.description = input.description
|
|
202
|
+
if (input.owner) cur.owner = input.owner
|
|
203
|
+
if (input.activeForm) cur.activeForm = input.activeForm
|
|
204
|
+
s.currentTodos.set(id, cur)
|
|
205
|
+
return
|
|
206
|
+
}
|
|
207
|
+
// TaskList / TaskGet / TaskStop / TaskOutput / TaskDelete:
|
|
208
|
+
// read-only or parse-heavy — skip mirror update. The panel will
|
|
209
|
+
// still render the SDK's textual result below the operation
|
|
210
|
+
// block, which is enough to disambiguate.
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** Re-render every Task* panel in the current turn (except the one
|
|
215
|
+
* that just landed — already up-to-date) so they all show the latest
|
|
216
|
+
* todo mirror in their footers. Cheap: ELEMENTS.tool(i) replace is
|
|
217
|
+
* queued through the per-card Promise chain like any other op. */
|
|
218
|
+
export function refreshOtherTaskPanels(s: Session, skipToolUseId: string): void {
|
|
219
|
+
if (!s.currentTurn) return
|
|
220
|
+
const todos = todosArray(s)
|
|
221
|
+
for (const [id, meta] of s.currentTurn.toolByUseId) {
|
|
222
|
+
if (id === skipToolUseId) continue
|
|
223
|
+
if (!isTaskWorkflow(meta.name)) continue
|
|
224
|
+
const status: '⏳' | '✅' | '❌' = meta.output === undefined
|
|
225
|
+
? '⏳'
|
|
226
|
+
: (meta.isError ? '❌' : '✅')
|
|
227
|
+
const el = cards.toolCallElement(
|
|
228
|
+
meta.i, meta.name, meta.input, meta.output ?? null,
|
|
229
|
+
status, meta.resolvedNote, todos,
|
|
230
|
+
)
|
|
231
|
+
void cardkit.replaceElement(s.currentTurn.cardId, cards.ELEMENTS.tool(meta.i), el)
|
|
232
|
+
}
|
|
233
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared types split out of session.ts so the main file stays under
|
|
3
|
+
* Claude Code's per-read token budget (~25K). Pure type-only — no
|
|
4
|
+
* runtime imports here. Companion modules: session-tools.ts,
|
|
5
|
+
* session-ask.ts, session-permission.ts.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export interface TurnState {
|
|
9
|
+
cardId: string
|
|
10
|
+
/** Feishu message_id of the card — needed for urgent_app push on clean
|
|
11
|
+
* turn close. Kept separate from cardId because cardkit's stream APIs
|
|
12
|
+
* operate on card_id but the urgent_app endpoint takes message_id. */
|
|
13
|
+
messageId: string
|
|
14
|
+
/** open_id of the user who started this turn. Used to scope the
|
|
15
|
+
* urgent_app push so only the initiator gets pinged (in case there
|
|
16
|
+
* are other members in the group). Empty string → skip the ping. */
|
|
17
|
+
userOpenId: string
|
|
18
|
+
/** What kicked off this turn. Only `'user_message'` turns fire the
|
|
19
|
+
* end-of-turn urgent_app push — scheduled / cron / loop wakeups
|
|
20
|
+
* finish on their own time and pinging the user would be noise,
|
|
21
|
+
* not signal. Ask / permission urgents inside the turn still fire
|
|
22
|
+
* regardless (those genuinely need attention even mid-schedule). */
|
|
23
|
+
trigger: 'user_message' | 'scheduled'
|
|
24
|
+
thinkingText: string
|
|
25
|
+
toolCount: number
|
|
26
|
+
/** `output` / `isError` are filled in by completeTool — kept on the
|
|
27
|
+
* meta (instead of being thrown away after the first render) so a
|
|
28
|
+
* later Task* op can re-render every prior Task* panel with the
|
|
29
|
+
* latest todo mirror appended. */
|
|
30
|
+
toolByUseId: Map<string, {
|
|
31
|
+
i: number
|
|
32
|
+
name: string
|
|
33
|
+
input: any
|
|
34
|
+
resolvedNote?: string
|
|
35
|
+
output?: string
|
|
36
|
+
isError?: boolean
|
|
37
|
+
/** Set when this tool is part of a merged Read batch — points to the
|
|
38
|
+
* batch's slot in `readBatches[i].items`. completeTool uses it to
|
|
39
|
+
* update the right row instead of rendering a standalone panel. */
|
|
40
|
+
readBatchSlot?: number
|
|
41
|
+
}>
|
|
42
|
+
/** Consecutive `Read` calls collapse into a single panel rendered by
|
|
43
|
+
* `cards.readBatchElement`. Keyed by element index `i` so completeTool
|
|
44
|
+
* can find the batch after its open-window closed (a non-Read tool or
|
|
45
|
+
* new assistant segment has since arrived).
|
|
46
|
+
*
|
|
47
|
+
* `openReadBatchI` is the i of the batch currently accepting new Reads;
|
|
48
|
+
* null once the run ends. Subsequent Read calls open a fresh batch at a
|
|
49
|
+
* new i. */
|
|
50
|
+
readBatches: Map<number, {
|
|
51
|
+
items: Array<{ toolUseId: string; input: any; output: string | null; isError: boolean }>
|
|
52
|
+
}>
|
|
53
|
+
openReadBatchI: number | null
|
|
54
|
+
assistantSegmentCount: number
|
|
55
|
+
currentAssistantSegmentId: string | null
|
|
56
|
+
currentAssistantText: string
|
|
57
|
+
// Per-assistant-segment cumulative text — used at turn close to strip
|
|
58
|
+
// [[send: /path]] markers and replace each segment with a cleaned
|
|
59
|
+
// version, then post the files as separate Feishu messages.
|
|
60
|
+
segmentTexts: Map<string, string>
|
|
61
|
+
startedAt: number
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export type Status = 'idle' | 'working' | 'awaiting_permission' | 'starting' | 'stopped'
|
|
65
|
+
|
|
66
|
+
export interface SessionOpts {
|
|
67
|
+
permissionMode?: 'default' | 'acceptEdits' | 'plan' | 'bypassPermissions'
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Per-turn delta extracted from the SDK `result` message — feeds the
|
|
71
|
+
* "上一轮" line in the console panel. */
|
|
72
|
+
export interface LastTurnDelta {
|
|
73
|
+
tokens: number // input + cache_* + output for that turn
|
|
74
|
+
costUsd: number
|
|
75
|
+
durationMs: number
|
|
76
|
+
inputTokens: number // input + cache_* (excludes output) — context-window estimate
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Cumulative session counters. Reset on full restart (`clear`),
|
|
80
|
+
* preserved across `restart`/resume and daemon-restart so the `hi`
|
|
81
|
+
* panel reflects the user's total spend in this conversation
|
|
82
|
+
* regardless of how many times the underlying ClaudeProcess has been
|
|
83
|
+
* respawned. Resumed conversations start counting from the resume
|
|
84
|
+
* point onward — the SDK doesn't replay historical usage on resume,
|
|
85
|
+
* so a long pre-resume conversation shows up as zero here until the
|
|
86
|
+
* first new turn lands. */
|
|
87
|
+
export interface CumStats {
|
|
88
|
+
tokens: number
|
|
89
|
+
costUsd: number
|
|
90
|
+
turns: number
|
|
91
|
+
}
|