@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/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')
|
package/src/session-ask.ts
DELETED
|
@@ -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
|
-
}
|
package/src/session-tools.ts
DELETED
|
@@ -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
|
-
}
|
package/src/session-types.ts
DELETED
|
@@ -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
|
-
}
|