@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/src/paths.ts DELETED
@@ -1,57 +0,0 @@
1
- /**
2
- * Filesystem layout — XDG Base Directory spec, with env-var overrides.
3
- *
4
- * Config: $LODESTAR_CONFIG_DIR | $XDG_CONFIG_HOME/lodestar | ~/.config/lodestar
5
- * Data: $LODESTAR_DATA_DIR | $XDG_DATA_HOME/lodestar | ~/.local/share/lodestar
6
- *
7
- * config.toml — credentials + preferences (in CONFIG_DIR)
8
- * daemon.pid — single-instance lock (in DATA_DIR)
9
- * daemon.log — append-only run log (in DATA_DIR)
10
- * session-chat-map.json — duplicate-name routing (in DATA_DIR)
11
- * session-resume-map.json — last-known claude session_id (in DATA_DIR)
12
- * inbox/ — downloaded attachments (in DATA_DIR)
13
- */
14
-
15
- import { homedir } from 'node:os'
16
- import { join } from 'node:path'
17
-
18
- const HOME = homedir()
19
-
20
- function pickDir(envOverride: string | undefined, xdgVar: string | undefined, fallback: string): string {
21
- if (envOverride) return envOverride
22
- if (xdgVar) return join(xdgVar, 'lodestar')
23
- return fallback
24
- }
25
-
26
- export const CONFIG_DIR = pickDir(
27
- process.env.LODESTAR_CONFIG_DIR,
28
- process.env.XDG_CONFIG_HOME,
29
- join(HOME, '.config', 'lodestar'),
30
- )
31
-
32
- export const DATA_DIR = pickDir(
33
- process.env.LODESTAR_DATA_DIR,
34
- process.env.XDG_DATA_HOME,
35
- join(HOME, '.local', 'share', 'lodestar'),
36
- )
37
-
38
- export const CONFIG_FILE = process.env.LODESTAR_CONFIG ?? join(CONFIG_DIR, 'config.toml')
39
- export const PID_FILE = join(DATA_DIR, 'daemon.pid')
40
- export const LOG_FILE = join(DATA_DIR, 'daemon.log')
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')
43
- /** Marker file written at shutdown listing the session names that
44
- * were still alive. The next daemon boot reads it (and unlinks it)
45
- * to auto-revive those sessions via `restart(true)` — bridges the
46
- * gap between systemctl-restart killing every child Claude and
47
- * Lodestar's "you have to send a message to re-spawn it" default. */
48
- export const ALIVE_MARKER_FILE = join(DATA_DIR, 'alive-on-shutdown.json')
49
- export const INBOX_DIR = join(DATA_DIR, 'inbox')
50
- /** Unix-socket endpoint the daemon listens on for debug message
51
- * injection (see scripts/test-inject.ts). A real Feishu user sends
52
- * a `[DEBUG]…` text once to seed `DEBUG_CTX_FILE` with chat_id +
53
- * sender_open_id; the injector then POSTs `{text}` here and the
54
- * daemon replays it through `handleMessage` as if WS had delivered
55
- * it. File mode 0600 — daemon-private, never network-exposed. */
56
- export const DEBUG_SOCK_FILE = join(DATA_DIR, 'debug.sock')
57
- export const DEBUG_CTX_FILE = join(DATA_DIR, 'debug-context.json')
@@ -1,165 +0,0 @@
1
- /**
2
- * AskUserQuestion flow split out of session.ts. The SDK routes
3
- * AskUserQuestion through can_use_tool even under bypass mode, so the
4
- * "answered" state lives across two SDK control messages — option
5
- * clicks/custom text land via Feishu callbacks first, then
6
- * can_use_tool arrives and we finalize with `updatedInput.answers`.
7
- */
8
-
9
- import type { Session } from './session'
10
- import * as cardkit from './cardkit'
11
- import * as cards from './cards'
12
- import { log } from './log'
13
-
14
- /** True iff there's at least one open AskUserQuestion awaiting an
15
- * answer in this session. `daemon.handleMessage` uses this to
16
- * decide whether an inbound chat message should be a custom answer
17
- * (routed to onAskMessageAnswer) instead of opening a new turn. */
18
- export function hasPendingAsk(s: Session): boolean {
19
- return s.pendingAsks.size > 0
20
- }
21
-
22
- /** Funnel an arbitrary chat message into the *current* question
23
- * of the oldest pending ask as a `customText` answer. Multi-
24
- * question semantics: from the user's perspective, the chat
25
- * input always answers whatever question is on screen right now
26
- * (`pending.currentIdx`), and a new question slides in after. */
27
- export async function onAskMessageAnswer(s: Session, text: string, user: string): Promise<void> {
28
- const firstEntry = s.pendingAsks.entries().next()
29
- if (firstEntry.done) {
30
- log(`session "${s.sessionName}": onAskMessageAnswer with no pending — falling back to onUserMessage`)
31
- await s.onUserMessage(text)
32
- return
33
- }
34
- const [toolUseId, pending] = firstEntry.value
35
- if (pending.currentIdx === undefined) {
36
- log(`session "${s.sessionName}": pending ask ${toolUseId} already terminal — ignoring message`)
37
- return
38
- }
39
- await onAskCustomAnswer(s, toolUseId, pending.currentIdx, text, user)
40
- }
41
-
42
- /** Click handler for an option button. The click must target the
43
- * question currently on screen (`pending.currentIdx`); a stale
44
- * click (e.g. user clicked an older render before it swapped in
45
- * the next question) is logged and dropped — better than double-
46
- * answering. */
47
- export async function onAskAnswer(
48
- s: Session,
49
- toolUseId: string,
50
- questionIdx: number,
51
- optionIdx: number,
52
- user: string,
53
- ): Promise<void> {
54
- const pending = s.pendingAsks.get(toolUseId)
55
- if (!pending) { log(`session "${s.sessionName}": stray ask answer for ${toolUseId}`); return }
56
- if (questionIdx !== pending.currentIdx) {
57
- log(`session "${s.sessionName}": stale ask click q=${questionIdx} current=${pending.currentIdx}`)
58
- return
59
- }
60
- advanceAsk(s, toolUseId, { optionIdx, user })
61
- }
62
-
63
- /** Custom-text branch. Same staleness rule as onAskAnswer; empty
64
- * input is silently ignored (panel stays pending). */
65
- export async function onAskCustomAnswer(
66
- s: Session,
67
- toolUseId: string,
68
- questionIdx: number,
69
- customText: string,
70
- user: string,
71
- ): Promise<void> {
72
- const pending = s.pendingAsks.get(toolUseId)
73
- if (!pending) { log(`session "${s.sessionName}": stray ask custom for ${toolUseId}`); return }
74
- const trimmed = (customText ?? '').trim()
75
- if (!trimmed) { log(`session "${s.sessionName}": empty custom answer, ignoring`); return }
76
- if (questionIdx !== pending.currentIdx) {
77
- log(`session "${s.sessionName}": stale ask custom q=${questionIdx} current=${pending.currentIdx}`)
78
- return
79
- }
80
- advanceAsk(s, toolUseId, { customText: trimmed, user })
81
- }
82
-
83
- /** Record an answer for the current question, advance the state
84
- * machine, repaint. If every question is now answered, finalize
85
- * (or defer the finalize until can_use_tool lands — the race is
86
- * handled by renderPermission). */
87
- export function advanceAsk(
88
- s: Session,
89
- toolUseId: string,
90
- answer: { optionIdx?: number; customText?: string; user: string },
91
- ): void {
92
- const pending = s.pendingAsks.get(toolUseId)
93
- if (!pending || pending.currentIdx === undefined) return
94
- const cur = pending.currentIdx
95
- const q = pending.questions[cur]
96
- if (!q) { log(`session "${s.sessionName}": advanceAsk currentIdx=${cur} out of range`); return }
97
- // Resolve the literal answer value — custom text wins if both set.
98
- let value: string
99
- if (answer.customText !== undefined) {
100
- value = answer.customText
101
- } else if (answer.optionIdx !== undefined) {
102
- const opt = q.options?.[answer.optionIdx]
103
- if (!opt) { log(`session "${s.sessionName}": advanceAsk option ${answer.optionIdx} out of range`); return }
104
- value = opt.label
105
- } else {
106
- log(`session "${s.sessionName}": advanceAsk with neither customText nor optionIdx`)
107
- return
108
- }
109
- pending.answers[q.question] = value
110
- pending.answered.set(cur, {
111
- optionIdx: answer.optionIdx,
112
- customText: answer.customText,
113
- user: answer.user,
114
- })
115
- // Next unanswered idx — linear from cur+1. Implementation
116
- // always moves forward; we don't currently let users revisit a
117
- // previous question (would need richer UI affordance for that).
118
- const total = pending.questions.length
119
- let nextIdx: number | undefined = undefined
120
- for (let i = cur + 1; i < total; i++) {
121
- if (!pending.answered.has(i)) { nextIdx = i; break }
122
- }
123
- pending.currentIdx = nextIdx
124
-
125
- const turn = s.currentTurn
126
- const meta = turn?.toolByUseId.get(toolUseId)
127
- if (turn && meta) {
128
- const el = cards.askUserQuestionElement(
129
- meta.i, toolUseId, pending.questions,
130
- nextIdx === undefined ? '✅' : '🤔',
131
- { currentIdx: nextIdx, answered: pending.answered },
132
- )
133
- void cardkit.replaceElement(turn.cardId, cards.ELEMENTS.tool(meta.i), el)
134
- }
135
-
136
- if (nextIdx === undefined) {
137
- // All done. Finalize iff we have the permission request id;
138
- // otherwise renderPermission will pick it up when it arrives.
139
- if (pending.requestId) finalizeAsk(s, toolUseId)
140
- else log(`session "${s.sessionName}": ask ${toolUseId} all answered, waiting for can_use_tool`)
141
- }
142
- }
143
-
144
- /** Settle a fully-answered AskUserQuestion: emit the SDK allow
145
- * with the full `answers` record folded into `updatedInput`,
146
- * drop bookkeeping, restore status. The terminal panel paint was
147
- * already done by the final advanceAsk; this is just protocol. */
148
- export function finalizeAsk(s: Session, toolUseId: string): void {
149
- const pending = s.pendingAsks.get(toolUseId)
150
- if (!pending || !pending.requestId) return
151
- const meta = s.currentTurn?.toolByUseId.get(toolUseId)
152
- const originalInput = meta?.input ?? {}
153
- s.proc?.sendPermissionResponse(pending.requestId, 'allow', {
154
- updatedInput: { ...originalInput, answers: pending.answers },
155
- })
156
- s.pendingPermissions.delete(pending.requestId)
157
- if (meta) {
158
- meta.output = JSON.stringify({ answers: pending.answers })
159
- meta.isError = false
160
- }
161
- s.pendingAsks.delete(toolUseId)
162
- if (s.pendingPermissions.size === 0 && s.status === 'awaiting_permission') {
163
- s.status = 'working'
164
- }
165
- }
@@ -1,136 +0,0 @@
1
- /**
2
- * Permission flow split out of session.ts. The daemon merges the
3
- * permission ask into the existing tool element in the current turn
4
- * card — one continuous timeline: ⏳ pending → 🔐 awaiting approval
5
- * (with buttons) → ⏳ allowed / ❌ denied → ✅ with output. No
6
- * floating orange card.
7
- */
8
-
9
- import type { Session } from './session'
10
- import type { CanUseToolRequest } from './claude-process'
11
- import * as cardkit from './cardkit'
12
- import * as cards from './cards'
13
- import * as feishu from './feishu'
14
- import { log } from './log'
15
- import { isTaskWorkflow, todosArray } from './session-tools'
16
- import { finalizeAsk } from './session-ask'
17
-
18
- export async function onPermissionDecision(
19
- s: Session,
20
- requestId: string,
21
- decision: 'allow' | 'allow_always' | 'deny',
22
- user: string,
23
- ): Promise<void> {
24
- const pending = s.pendingPermissions.get(requestId)
25
- if (!pending) { log(`session "${s.sessionName}": stray permission ${requestId}`); return }
26
- s.pendingPermissions.delete(requestId)
27
-
28
- // Update the tool element in the main turn card in place — the
29
- // permission decision lives on the same row as the tool call.
30
- const turn = s.currentTurn
31
- const meta = turn?.toolByUseId.get(pending.toolUseId)
32
- if (turn && meta) {
33
- const todos = isTaskWorkflow(meta.name) ? todosArray(s) : undefined
34
- if (decision === 'deny') {
35
- const el = cards.toolCallElement(meta.i, meta.name, meta.input, `🚫 已拒绝 by ${user || '匿名'}`, '❌', undefined, todos)
36
- void cardkit.replaceElement(turn.cardId, cards.ELEMENTS.tool(meta.i), el)
37
- } else {
38
- const label = decision === 'allow_always' ? '始终允许' : '已允许'
39
- meta.resolvedNote = `✅ **${label}** by ${user || '匿名'}`
40
- const el = cards.toolCallElement(meta.i, meta.name, meta.input, null, '⏳', meta.resolvedNote, todos)
41
- void cardkit.replaceElement(turn.cardId, cards.ELEMENTS.tool(meta.i), el)
42
- }
43
- }
44
-
45
- const claudeDecision = decision === 'deny' ? 'deny' : 'allow'
46
- s.proc?.sendPermissionResponse(requestId, claudeDecision)
47
-
48
- if (decision === 'allow_always') {
49
- s.proc?.sendSetPermissionMode('acceptEdits')
50
- }
51
-
52
- if (s.pendingPermissions.size === 0 && s.status === 'awaiting_permission') {
53
- s.status = 'working'
54
- }
55
- }
56
-
57
- /** Merge the permission ask into the existing tool element in the
58
- * current turn card. The user sees one continuous timeline: ⏳ pending
59
- * → 🔐 awaiting approval (with buttons) → ⏳ allowed / ❌ denied → ✅
60
- * with output. No floating orange card.
61
- *
62
- * `tool_use` is emitted as part of the assistant message and lands on
63
- * our `addTool` handler BEFORE the SDK's `can_use_tool` control_request
64
- * arrives — so by the time we get here, `toolByUseId` already has the
65
- * entry we need to replace.
66
- *
67
- * Edge cases (no current turn / missing tool_use_id / unknown id) are
68
- * surfaced loudly and auto-denied. We don't fall back to a standalone
69
- * card — per the project's no-fallbacks rule, hidden anomalies are
70
- * worse than visible deny errors. */
71
- export function renderPermission(s: Session, req: CanUseToolRequest): void {
72
- const turn = s.currentTurn
73
- if (!turn) {
74
- log(`session "${s.sessionName}": can_use_tool with no current turn — auto-deny req=${req.request_id}`)
75
- s.proc?.sendPermissionResponse(req.request_id, 'deny', { denyMessage: 'no active turn' })
76
- return
77
- }
78
- const toolUseId = req.tool_use_id
79
- if (!toolUseId) {
80
- log(`session "${s.sessionName}": can_use_tool without tool_use_id — auto-deny req=${req.request_id}`)
81
- s.proc?.sendPermissionResponse(req.request_id, 'deny', { denyMessage: 'no tool_use_id' })
82
- return
83
- }
84
- const meta = turn.toolByUseId.get(toolUseId)
85
- if (!meta) {
86
- log(`session "${s.sessionName}": can_use_tool for unknown tool_use_id=${toolUseId} — auto-deny req=${req.request_id}`)
87
- s.proc?.sendPermissionResponse(req.request_id, 'deny', { denyMessage: 'unknown tool_use_id' })
88
- return
89
- }
90
- // AskUserQuestion: SDK routes it through can_use_tool even under
91
- // bypass. The PAYLOAD of "user has answered" is the permission
92
- // response itself — specifically `updatedInput.answers`. So we
93
- // CANNOT auto-allow here (that's the v0.1.2 bug: SDK got an empty
94
- // answers map and immediately synthesised a "User has answered
95
- // your questions: ." tool_result). Park the requestId on the
96
- // pendingAsk record and wait for the user to click an option;
97
- // onAskAnswer will then send allow + updatedInput.answers in one
98
- // shot. If the user already clicked between addTool and now —
99
- // the deferredAnswer slot — settle immediately.
100
- if (meta.name === 'AskUserQuestion') {
101
- const ask = s.pendingAsks.get(toolUseId)
102
- if (!ask) {
103
- log(`session "${s.sessionName}": AskUserQuestion ${toolUseId} missing pendingAsk — deny`)
104
- s.proc?.sendPermissionResponse(req.request_id, 'deny', { denyMessage: 'no pending ask' })
105
- return
106
- }
107
- ask.requestId = req.request_id
108
- s.pendingPermissions.set(req.request_id, { toolUseId })
109
- // Fast-clicker race: the user may have answered every question
110
- // while we were still waiting for can_use_tool to arrive. If so,
111
- // advanceAsk parked the all-done state and we drain it now.
112
- if (ask.currentIdx === undefined) finalizeAsk(s, toolUseId)
113
- return
114
- }
115
- s.status = 'awaiting_permission'
116
- s.pendingPermissions.set(req.request_id, { toolUseId })
117
- const el = cards.toolCallPermissionElement(meta.i, meta.name, meta.input, req.request_id)
118
- void cardkit.replaceElement(turn.cardId, cards.ELEMENTS.tool(meta.i), el)
119
- // Phone push — Claude is blocked until the user approves/denies.
120
- // Set summary to "🔐 等审批: <tool>(<input summary>)" so the lock-
121
- // screen notification shows which tool needs approval.
122
- if (turn.userOpenId && turn.messageId) {
123
- const inputSummary = cards.summarizeToolInput(meta.name, meta.input)
124
- const tail = inputSummary && inputSummary.length > 30
125
- ? inputSummary.slice(0, 30) + '…'
126
- : inputSummary
127
- const summary = tail
128
- ? `🔐 等审批: ${meta.name} · ${tail}`
129
- : `🔐 等审批: ${meta.name}`
130
- void (async () => {
131
- cardkit.cancelSummary(turn.cardId)
132
- await cardkit.patchSettings(turn.cardId, { config: { summary: { content: summary } } })
133
- await feishu.urgentApp(turn.messageId, [turn.userOpenId])
134
- })()
135
- }
136
- }
@@ -1,233 +0,0 @@
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
- }
@@ -1,91 +0,0 @@
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
- }