@leviyuan/lodestar 0.1.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/LICENSE +21 -0
- package/README.md +104 -0
- package/daemon.ts +203 -0
- package/package.json +52 -0
- package/src/cardkit.ts +215 -0
- package/src/cards.ts +304 -0
- package/src/claude-process.ts +301 -0
- package/src/config.ts +83 -0
- package/src/feishu.ts +365 -0
- package/src/instructions.ts +22 -0
- package/src/log.ts +11 -0
- package/src/paths.ts +41 -0
- package/src/session.ts +447 -0
package/src/cards.ts
ADDED
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Schema 2.0 Feishu card templates.
|
|
3
|
+
*
|
|
4
|
+
* Element-id convention (must be unique within a card):
|
|
5
|
+
* user_input — the collapsible "你说" panel
|
|
6
|
+
* thinking — the de-emphasized thinking stream
|
|
7
|
+
* tool_<i> — one collapsible per tool call, indexed from 0
|
|
8
|
+
* assistant — the main streaming assistant answer
|
|
9
|
+
* footer — runtime footer (timing / status)
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export const ELEMENTS = {
|
|
13
|
+
thinking: 'thinking',
|
|
14
|
+
footer: 'footer',
|
|
15
|
+
tool: (i: number) => `tool_${i}`,
|
|
16
|
+
/** Assistant text is segmented: every tool call closes the running segment
|
|
17
|
+
* and the next assistant chunk opens a new one, so element order in the
|
|
18
|
+
* card matches Claude's emission order. */
|
|
19
|
+
assistant: (i: number) => `assistant_${i}`,
|
|
20
|
+
} as const
|
|
21
|
+
|
|
22
|
+
/** Single-line summary used as a collapsible-panel header for a tool call. */
|
|
23
|
+
export function summarizeToolInput(name: string, input: any): string {
|
|
24
|
+
if (!input || typeof input !== 'object') return ''
|
|
25
|
+
const truncate = (s: string, n: number) => s.length > n ? s.slice(0, n) + '…' : s
|
|
26
|
+
switch (name) {
|
|
27
|
+
case 'Bash': return truncate(String(input.command ?? ''), 80)
|
|
28
|
+
case 'Read':
|
|
29
|
+
case 'Write':
|
|
30
|
+
case 'Edit':
|
|
31
|
+
case 'NotebookEdit': return truncate(String(input.file_path ?? ''), 80)
|
|
32
|
+
case 'Glob': return truncate(String(input.pattern ?? ''), 80)
|
|
33
|
+
case 'Grep': return truncate(`${input.pattern ?? ''}${input.path ? ' in ' + input.path : ''}`, 80)
|
|
34
|
+
case 'WebFetch':
|
|
35
|
+
case 'WebSearch': return truncate(String(input.url ?? input.query ?? ''), 80)
|
|
36
|
+
case 'Agent':
|
|
37
|
+
case 'Task': return truncate(String(input.description ?? input.subject ?? ''), 80)
|
|
38
|
+
case 'Skill': return truncate(String(input.skill ?? ''), 80)
|
|
39
|
+
}
|
|
40
|
+
// generic fallback: first string-valued field
|
|
41
|
+
for (const v of Object.values(input)) {
|
|
42
|
+
if (typeof v === 'string' && v) return truncate(v, 80)
|
|
43
|
+
}
|
|
44
|
+
return ''
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
interface MainCardOpts {
|
|
48
|
+
sessionName: string
|
|
49
|
+
turn: number
|
|
50
|
+
model?: string
|
|
51
|
+
effort?: string
|
|
52
|
+
userText: string
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Initial card sent at the start of each turn. Streaming on. */
|
|
56
|
+
export function mainConversationCard(_opts: MainCardOpts): object {
|
|
57
|
+
return {
|
|
58
|
+
schema: '2.0',
|
|
59
|
+
config: {
|
|
60
|
+
streaming_mode: true,
|
|
61
|
+
summary: { content: '[Lodestar 正在生成…]' },
|
|
62
|
+
streaming_config: {
|
|
63
|
+
print_frequency_ms: { default: 60, android: 60, ios: 60, pc: 30 },
|
|
64
|
+
print_step: { default: 2, android: 2, ios: 2, pc: 4 },
|
|
65
|
+
print_strategy: 'fast',
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
body: {
|
|
69
|
+
// Initial body has just thinking + footer; assistant segments and tool
|
|
70
|
+
// panels are inserted between them in real time as Claude streams.
|
|
71
|
+
// Note: empty-string content is rejected by CardKit PUT so the
|
|
72
|
+
// thinking element starts with a single space placeholder; the first
|
|
73
|
+
// real append overwrites it.
|
|
74
|
+
elements: [
|
|
75
|
+
{ tag: 'markdown', element_id: ELEMENTS.thinking, content: ' ' },
|
|
76
|
+
{ tag: 'markdown', element_id: ELEMENTS.footer, content: '⏳ working…' },
|
|
77
|
+
],
|
|
78
|
+
},
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Empty assistant segment to be inserted just before the footer. */
|
|
83
|
+
export function assistantSegmentElement(i: number): object {
|
|
84
|
+
return { tag: 'markdown', element_id: ELEMENTS.assistant(i), content: ' ' }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Final state for the thinking section once a turn closes — collapse the
|
|
88
|
+
* full thinking text into a panel so the card stays clean. Replaces the
|
|
89
|
+
* top-level `thinking` markdown element via PUT /elements/:id. */
|
|
90
|
+
export function thinkingCollapsedPanel(fullText: string): object {
|
|
91
|
+
const trimmed = fullText.trim()
|
|
92
|
+
return {
|
|
93
|
+
tag: 'collapsible_panel',
|
|
94
|
+
element_id: ELEMENTS.thinking,
|
|
95
|
+
header: { title: { tag: 'plain_text', content: `💭 思考过程 (${trimmed.length} 字)` } },
|
|
96
|
+
expanded: false,
|
|
97
|
+
elements: [
|
|
98
|
+
{ tag: 'markdown', content: trimmed.slice(0, 8000) || '_(空)_' },
|
|
99
|
+
],
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Element to insert for each tool call. expandable for big results.
|
|
104
|
+
*
|
|
105
|
+
* Header is a one-line summary: status + name + summarized input.
|
|
106
|
+
* Body holds the full input + (after completion) the full output. */
|
|
107
|
+
export function toolCallElement(
|
|
108
|
+
i: number,
|
|
109
|
+
name: string,
|
|
110
|
+
input: any,
|
|
111
|
+
output: string | null,
|
|
112
|
+
status: '⏳' | '✅' | '❌' = '⏳',
|
|
113
|
+
): object {
|
|
114
|
+
const summary = summarizeToolInput(name, input)
|
|
115
|
+
const headerText = summary
|
|
116
|
+
? `${status} 🔧 ${name}: ${summary}`
|
|
117
|
+
: `${status} 🔧 ${name}`
|
|
118
|
+
const inputBlock = '```\n' + JSON.stringify(input ?? {}, null, 2).slice(0, 2000) + '\n```'
|
|
119
|
+
const outputBlock = output != null
|
|
120
|
+
? '\n---\n**output:**\n```\n' + output.slice(0, 3000) + '\n```'
|
|
121
|
+
: ''
|
|
122
|
+
return {
|
|
123
|
+
tag: 'collapsible_panel',
|
|
124
|
+
element_id: ELEMENTS.tool(i),
|
|
125
|
+
header: { title: { tag: 'plain_text', content: headerText } },
|
|
126
|
+
expanded: false,
|
|
127
|
+
elements: [
|
|
128
|
+
{ tag: 'markdown', content: inputBlock + outputBlock },
|
|
129
|
+
],
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
interface PermissionOpts {
|
|
134
|
+
sessionName: string
|
|
135
|
+
toolName: string
|
|
136
|
+
description: string
|
|
137
|
+
inputPreview: string
|
|
138
|
+
requestId: string
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function permissionCard(opts: PermissionOpts): object {
|
|
142
|
+
const { sessionName, toolName, description, inputPreview, requestId } = opts
|
|
143
|
+
let pretty = inputPreview
|
|
144
|
+
try { pretty = JSON.stringify(JSON.parse(inputPreview), null, 2) } catch {}
|
|
145
|
+
return {
|
|
146
|
+
schema: '2.0',
|
|
147
|
+
config: { update_multi: true },
|
|
148
|
+
header: {
|
|
149
|
+
title: { tag: 'plain_text', content: `🔐 权限请求 · ${toolName}` },
|
|
150
|
+
subtitle: { tag: 'plain_text', content: sessionName },
|
|
151
|
+
template: 'orange',
|
|
152
|
+
},
|
|
153
|
+
body: {
|
|
154
|
+
elements: [
|
|
155
|
+
{ tag: 'markdown', content: description },
|
|
156
|
+
{ tag: 'markdown', content: '```\n' + pretty.slice(0, 2000) + '\n```' },
|
|
157
|
+
{
|
|
158
|
+
tag: 'column_set',
|
|
159
|
+
columns: [
|
|
160
|
+
permissionButtonColumn('✅ 允许', 'primary', requestId, 'allow'),
|
|
161
|
+
permissionButtonColumn('♾️ 始终允许', 'default', requestId, 'allow_always'),
|
|
162
|
+
permissionButtonColumn('❌ 拒绝', 'danger', requestId, 'deny'),
|
|
163
|
+
],
|
|
164
|
+
},
|
|
165
|
+
],
|
|
166
|
+
},
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function permissionButtonColumn(label: string, type: string, requestId: string, decision: string): object {
|
|
171
|
+
return {
|
|
172
|
+
tag: 'column', width: 'weighted', weight: 1,
|
|
173
|
+
elements: [{
|
|
174
|
+
tag: 'button',
|
|
175
|
+
text: { tag: 'plain_text', content: label },
|
|
176
|
+
type,
|
|
177
|
+
behaviors: [{ type: 'callback', value: { kind: 'permission', request_id: requestId, decision } }],
|
|
178
|
+
}],
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export function permissionResolvedCard(
|
|
183
|
+
toolName: string,
|
|
184
|
+
decision: 'allow' | 'allow_always' | 'deny',
|
|
185
|
+
user: string,
|
|
186
|
+
): object {
|
|
187
|
+
const ok = decision !== 'deny'
|
|
188
|
+
const label = decision === 'allow_always' ? '始终允许' : decision === 'allow' ? '已允许' : '已拒绝'
|
|
189
|
+
return {
|
|
190
|
+
schema: '2.0',
|
|
191
|
+
config: { update_multi: true },
|
|
192
|
+
header: {
|
|
193
|
+
title: { tag: 'plain_text', content: `🔐 权限请求 · ${toolName}` },
|
|
194
|
+
template: ok ? 'green' : 'red',
|
|
195
|
+
},
|
|
196
|
+
body: {
|
|
197
|
+
elements: [{
|
|
198
|
+
tag: 'markdown',
|
|
199
|
+
content: `${ok ? '✅' : '❌'} **${label}** by ${user || '匿名'} · ${new Date().toLocaleTimeString('zh-CN', { hour12: false })}`,
|
|
200
|
+
}],
|
|
201
|
+
},
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
interface ConsoleOpts {
|
|
206
|
+
sessionName: string
|
|
207
|
+
status: 'idle' | 'working' | 'awaiting_permission' | 'starting' | 'stopped'
|
|
208
|
+
model?: string
|
|
209
|
+
effort?: string
|
|
210
|
+
uptime?: string
|
|
211
|
+
lastActivity?: string
|
|
212
|
+
hasSession: boolean
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export function consoleCard(opts: ConsoleOpts): object {
|
|
216
|
+
const { sessionName, status, model, effort, uptime, lastActivity, hasSession } = opts
|
|
217
|
+
const statusEmoji = {
|
|
218
|
+
idle: '🟢 闲', working: '⚙️ 工作中', awaiting_permission: '🔐 等审批',
|
|
219
|
+
starting: '🚀 启动中', stopped: '⚪ 未运行',
|
|
220
|
+
}[status]
|
|
221
|
+
const meta = [
|
|
222
|
+
`状态: ${statusEmoji}`,
|
|
223
|
+
model ? `模型: ${model}${effort ? `/${effort}` : ''}` : null,
|
|
224
|
+
uptime ? `运行: ${uptime}` : null,
|
|
225
|
+
lastActivity ? `最近: ${lastActivity}` : null,
|
|
226
|
+
].filter(Boolean).join(' · ')
|
|
227
|
+
|
|
228
|
+
const buttons: [string, string, string][] = hasSession
|
|
229
|
+
? [
|
|
230
|
+
['⏸ 中断', 'interrupt', 'default'],
|
|
231
|
+
['🧹 /clear', 'clear', 'default'],
|
|
232
|
+
['⏹ 终止', 'stop', 'danger'],
|
|
233
|
+
['📁 ls', 'ls', 'default'],
|
|
234
|
+
]
|
|
235
|
+
: [
|
|
236
|
+
['🚀 启动', 'start', 'primary'],
|
|
237
|
+
['🔁 续聊', 'resume', 'default'],
|
|
238
|
+
['📁 ls', 'ls', 'default'],
|
|
239
|
+
]
|
|
240
|
+
|
|
241
|
+
const template = status === 'working' ? 'blue'
|
|
242
|
+
: status === 'awaiting_permission' ? 'orange'
|
|
243
|
+
: status === 'stopped' ? 'grey'
|
|
244
|
+
: 'green'
|
|
245
|
+
|
|
246
|
+
return {
|
|
247
|
+
schema: '2.0',
|
|
248
|
+
config: { update_multi: true },
|
|
249
|
+
header: {
|
|
250
|
+
title: { tag: 'plain_text', content: `🌟 Lodestar · ${sessionName}` },
|
|
251
|
+
template,
|
|
252
|
+
},
|
|
253
|
+
body: {
|
|
254
|
+
elements: [
|
|
255
|
+
{ tag: 'markdown', content: meta || '_(no state)_' },
|
|
256
|
+
{
|
|
257
|
+
tag: 'column_set',
|
|
258
|
+
columns: buttons.map(([label, action, kind]) => ({
|
|
259
|
+
tag: 'column', width: 'weighted', weight: 1,
|
|
260
|
+
elements: [{
|
|
261
|
+
tag: 'button',
|
|
262
|
+
text: { tag: 'plain_text', content: label },
|
|
263
|
+
type: kind,
|
|
264
|
+
behaviors: [{ type: 'callback', value: { kind: 'console', action } }],
|
|
265
|
+
}],
|
|
266
|
+
})),
|
|
267
|
+
},
|
|
268
|
+
],
|
|
269
|
+
},
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
interface MenuOpts {
|
|
274
|
+
question: string
|
|
275
|
+
options: string[]
|
|
276
|
+
requestId: string
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
export function menuCard(opts: MenuOpts): object {
|
|
280
|
+
const { question, options, requestId } = opts
|
|
281
|
+
return {
|
|
282
|
+
schema: '2.0',
|
|
283
|
+
config: { update_multi: true },
|
|
284
|
+
header: {
|
|
285
|
+
title: { tag: 'plain_text', content: '📋 等待选择' },
|
|
286
|
+
template: 'turquoise',
|
|
287
|
+
},
|
|
288
|
+
body: {
|
|
289
|
+
elements: [
|
|
290
|
+
{ tag: 'markdown', content: question || '_请选择一项:_' },
|
|
291
|
+
...options.map((opt, i) => ({
|
|
292
|
+
tag: 'button',
|
|
293
|
+
text: { tag: 'plain_text', content: opt },
|
|
294
|
+
type: 'default',
|
|
295
|
+
behaviors: [{ type: 'callback', value: { kind: 'menu', request_id: requestId, choice: i } }],
|
|
296
|
+
})),
|
|
297
|
+
],
|
|
298
|
+
},
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
export const STREAMING_OFF_SETTINGS = {
|
|
303
|
+
config: { streaming_mode: false, summary: { content: '✅ Lodestar 完成' } },
|
|
304
|
+
}
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Headless Claude Code subprocess + SDK control protocol over NDJSON.
|
|
3
|
+
*
|
|
4
|
+
* Spawned with:
|
|
5
|
+
* claude -p
|
|
6
|
+
* --input-format=stream-json
|
|
7
|
+
* --output-format=stream-json
|
|
8
|
+
* --verbose
|
|
9
|
+
* --permission-prompt-tool=stdio
|
|
10
|
+
* --permission-mode={mode}
|
|
11
|
+
* [--effort=max] [--model=...] [--append-system-prompt=...]
|
|
12
|
+
* [--resume <id> --resume-session-at <uuid>]
|
|
13
|
+
*
|
|
14
|
+
* Stdin / stdout are line-delimited JSON. Stdout flows assistant chunks,
|
|
15
|
+
* tool_use, tool_result, control_request (can_use_tool / hook_callback)
|
|
16
|
+
* and finally `result` per turn. Stdin carries user messages and our
|
|
17
|
+
* outbound control_responses + control_requests (Initialize / Interrupt /
|
|
18
|
+
* SetPermissionMode).
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { spawn, type ChildProcessByStdio } from 'node:child_process'
|
|
22
|
+
import { homedir } from 'node:os'
|
|
23
|
+
import { join } from 'node:path'
|
|
24
|
+
import { EventEmitter } from 'node:events'
|
|
25
|
+
import type { Readable, Writable } from 'node:stream'
|
|
26
|
+
import { log } from './log'
|
|
27
|
+
|
|
28
|
+
interface SpawnOpts {
|
|
29
|
+
workDir: string
|
|
30
|
+
resumeSessionId?: string
|
|
31
|
+
resumeAtUuid?: string
|
|
32
|
+
model?: string
|
|
33
|
+
effort?: 'low' | 'medium' | 'high' | 'max'
|
|
34
|
+
permissionMode?: 'default' | 'acceptEdits' | 'plan' | 'bypassPermissions'
|
|
35
|
+
appendSystemPrompt?: string
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface CanUseToolRequest {
|
|
39
|
+
request_id: string
|
|
40
|
+
tool_name: string
|
|
41
|
+
input: any
|
|
42
|
+
permission_suggestions?: any
|
|
43
|
+
blocked_paths?: string[]
|
|
44
|
+
tool_use_id?: string
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface HookCallbackRequest {
|
|
48
|
+
request_id: string
|
|
49
|
+
callback_id: string
|
|
50
|
+
input: any
|
|
51
|
+
tool_use_id?: string
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export class ClaudeProcess extends EventEmitter {
|
|
55
|
+
private proc: ChildProcessByStdio<Writable, Readable, Readable>
|
|
56
|
+
private stdoutBuf = ''
|
|
57
|
+
private stderrBuf = ''
|
|
58
|
+
private requestCounter = 0
|
|
59
|
+
private alive = true
|
|
60
|
+
private expectedExit = false
|
|
61
|
+
sessionId: string | null = null
|
|
62
|
+
lastAssistantUuid: string | null = null
|
|
63
|
+
|
|
64
|
+
constructor(opts: SpawnOpts) {
|
|
65
|
+
super()
|
|
66
|
+
const claudeBin = join(homedir(), '.local', 'bin', 'claude')
|
|
67
|
+
const args = [
|
|
68
|
+
'-p',
|
|
69
|
+
'--input-format=stream-json',
|
|
70
|
+
'--output-format=stream-json',
|
|
71
|
+
'--verbose',
|
|
72
|
+
'--permission-prompt-tool=stdio',
|
|
73
|
+
`--permission-mode=${opts.permissionMode ?? 'default'}`,
|
|
74
|
+
]
|
|
75
|
+
if (opts.model) args.push('--model', opts.model)
|
|
76
|
+
if (opts.effort) args.push('--effort', opts.effort)
|
|
77
|
+
if (opts.appendSystemPrompt) args.push('--append-system-prompt', opts.appendSystemPrompt)
|
|
78
|
+
if (opts.resumeSessionId) {
|
|
79
|
+
args.push('--resume', opts.resumeSessionId)
|
|
80
|
+
if (opts.resumeAtUuid) args.push('--resume-session-at', opts.resumeAtUuid)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
log(`claude-process: spawn ${claudeBin} (cwd=${opts.workDir})`)
|
|
84
|
+
this.proc = spawn(claudeBin, args, {
|
|
85
|
+
cwd: opts.workDir,
|
|
86
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
87
|
+
env: {
|
|
88
|
+
...process.env,
|
|
89
|
+
NPM_CONFIG_LOGLEVEL: 'error',
|
|
90
|
+
PATH: [
|
|
91
|
+
join(homedir(), '.local', 'bin'),
|
|
92
|
+
join(homedir(), '.bun', 'bin'),
|
|
93
|
+
join(homedir(), '.local', 'npm-global', 'bin'),
|
|
94
|
+
'/usr/local/bin', '/usr/bin', '/bin',
|
|
95
|
+
].join(':'),
|
|
96
|
+
},
|
|
97
|
+
}) as ChildProcessByStdio<Writable, Readable, Readable>
|
|
98
|
+
|
|
99
|
+
this.proc.stdout.on('data', (chunk: Buffer) => this.onStdout(chunk))
|
|
100
|
+
this.proc.stderr.on('data', (chunk: Buffer) => this.onStderr(chunk))
|
|
101
|
+
this.proc.on('exit', (code, signal) => {
|
|
102
|
+
this.alive = false
|
|
103
|
+
log(`claude-process: exited code=${code} signal=${signal} expected=${this.expectedExit}`)
|
|
104
|
+
this.emit('exit', { code, signal, expected: this.expectedExit })
|
|
105
|
+
})
|
|
106
|
+
this.proc.on('error', err => {
|
|
107
|
+
log(`claude-process: spawn error: ${err}`)
|
|
108
|
+
this.emit('error', err)
|
|
109
|
+
})
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ── Stream parsers ─────────────────────────────────────────────────
|
|
113
|
+
private onStdout(chunk: Buffer): void {
|
|
114
|
+
this.stdoutBuf += chunk.toString()
|
|
115
|
+
let nl: number
|
|
116
|
+
while ((nl = this.stdoutBuf.indexOf('\n')) >= 0) {
|
|
117
|
+
const line = this.stdoutBuf.slice(0, nl).trim()
|
|
118
|
+
this.stdoutBuf = this.stdoutBuf.slice(nl + 1)
|
|
119
|
+
if (!line) continue
|
|
120
|
+
try {
|
|
121
|
+
const msg = JSON.parse(line)
|
|
122
|
+
this.handleMessage(msg)
|
|
123
|
+
} catch (e) {
|
|
124
|
+
log(`claude-process: bad json: ${line.slice(0, 200)} (${e})`)
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
private onStderr(chunk: Buffer): void {
|
|
130
|
+
this.stderrBuf += chunk.toString()
|
|
131
|
+
let nl: number
|
|
132
|
+
while ((nl = this.stderrBuf.indexOf('\n')) >= 0) {
|
|
133
|
+
const line = this.stderrBuf.slice(0, nl)
|
|
134
|
+
this.stderrBuf = this.stderrBuf.slice(nl + 1)
|
|
135
|
+
if (line.trim()) log(`claude-process[stderr]: ${line}`)
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
private handleMessage(msg: any): void {
|
|
140
|
+
const type = msg.type
|
|
141
|
+
if (type === 'system' && msg.subtype === 'init') {
|
|
142
|
+
this.sessionId = msg.session_id ?? null
|
|
143
|
+
log(`claude-process: session=${this.sessionId}`)
|
|
144
|
+
this.emit('init', msg)
|
|
145
|
+
return
|
|
146
|
+
}
|
|
147
|
+
if (type === 'control_request') {
|
|
148
|
+
const req = msg.request
|
|
149
|
+
if (req?.subtype === 'can_use_tool') {
|
|
150
|
+
this.emit('can_use_tool', {
|
|
151
|
+
request_id: msg.request_id,
|
|
152
|
+
tool_name: req.tool_name,
|
|
153
|
+
input: req.input,
|
|
154
|
+
permission_suggestions: req.permission_suggestions,
|
|
155
|
+
blocked_paths: req.blocked_paths,
|
|
156
|
+
tool_use_id: req.tool_use_id,
|
|
157
|
+
} as CanUseToolRequest)
|
|
158
|
+
return
|
|
159
|
+
}
|
|
160
|
+
if (req?.subtype === 'hook_callback') {
|
|
161
|
+
this.emit('hook_callback', {
|
|
162
|
+
request_id: msg.request_id,
|
|
163
|
+
callback_id: req.callback_id,
|
|
164
|
+
input: req.input,
|
|
165
|
+
tool_use_id: req.tool_use_id,
|
|
166
|
+
} as HookCallbackRequest)
|
|
167
|
+
return
|
|
168
|
+
}
|
|
169
|
+
log(`claude-process: unknown control_request subtype=${req?.subtype}`)
|
|
170
|
+
return
|
|
171
|
+
}
|
|
172
|
+
if (type === 'control_response') {
|
|
173
|
+
this.emit('control_response', msg)
|
|
174
|
+
return
|
|
175
|
+
}
|
|
176
|
+
if (type === 'assistant') {
|
|
177
|
+
const content = msg.message?.content ?? []
|
|
178
|
+
for (const block of content) {
|
|
179
|
+
if (block.type === 'text' && typeof block.text === 'string') {
|
|
180
|
+
this.emit('assistant_text', { uuid: msg.uuid, text: block.text })
|
|
181
|
+
} else if (block.type === 'tool_use') {
|
|
182
|
+
this.emit('tool_use', { uuid: msg.uuid, id: block.id, name: block.name, input: block.input })
|
|
183
|
+
} else if (block.type === 'thinking' && typeof block.thinking === 'string') {
|
|
184
|
+
this.emit('thinking', { uuid: msg.uuid, text: block.thinking })
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
if (msg.uuid) this.lastAssistantUuid = msg.uuid
|
|
188
|
+
return
|
|
189
|
+
}
|
|
190
|
+
if (type === 'user') {
|
|
191
|
+
const content = msg.message?.content ?? []
|
|
192
|
+
for (const block of content) {
|
|
193
|
+
if (block.type === 'tool_result') {
|
|
194
|
+
this.emit('tool_result', {
|
|
195
|
+
tool_use_id: block.tool_use_id,
|
|
196
|
+
content: block.content,
|
|
197
|
+
is_error: block.is_error ?? false,
|
|
198
|
+
})
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
return
|
|
202
|
+
}
|
|
203
|
+
if (type === 'result') {
|
|
204
|
+
this.emit('result', msg)
|
|
205
|
+
return
|
|
206
|
+
}
|
|
207
|
+
this.emit('raw', msg)
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
private write(obj: object): void {
|
|
211
|
+
if (!this.alive) {
|
|
212
|
+
log(`claude-process: write to dead process: ${JSON.stringify(obj).slice(0, 200)}`)
|
|
213
|
+
return
|
|
214
|
+
}
|
|
215
|
+
try {
|
|
216
|
+
this.proc.stdin.write(JSON.stringify(obj) + '\n')
|
|
217
|
+
} catch (e) { log(`claude-process: stdin write failed: ${e}`) }
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// ── Outbound control ────────────────────────────────────────────────
|
|
221
|
+
sendInitialize(hooks: Record<string, any> = {}): void {
|
|
222
|
+
this.write({
|
|
223
|
+
type: 'control_request',
|
|
224
|
+
request_id: `init-${++this.requestCounter}`,
|
|
225
|
+
request: { subtype: 'initialize', hooks },
|
|
226
|
+
})
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
sendUserText(text: string, files: string[] = []): void {
|
|
230
|
+
const fileHints = files.length ? files.map(f => `[file: ${f}]`).join(' ') + '\n\n' : ''
|
|
231
|
+
this.write({
|
|
232
|
+
type: 'user',
|
|
233
|
+
message: {
|
|
234
|
+
role: 'user',
|
|
235
|
+
content: [{ type: 'text', text: fileHints + text }],
|
|
236
|
+
},
|
|
237
|
+
})
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
sendInterrupt(): void {
|
|
241
|
+
this.write({
|
|
242
|
+
type: 'control_request',
|
|
243
|
+
request_id: `int-${++this.requestCounter}`,
|
|
244
|
+
request: { subtype: 'interrupt' },
|
|
245
|
+
})
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
sendPermissionResponse(requestId: string, decision: 'allow' | 'deny', updatedInput?: any): void {
|
|
249
|
+
this.write({
|
|
250
|
+
type: 'control_response',
|
|
251
|
+
response: {
|
|
252
|
+
subtype: 'success',
|
|
253
|
+
request_id: requestId,
|
|
254
|
+
response: {
|
|
255
|
+
hookSpecificOutput: {
|
|
256
|
+
hookEventName: 'PreToolUse',
|
|
257
|
+
permissionDecision: decision,
|
|
258
|
+
...(updatedInput ? { permissionDecisionInput: updatedInput } : {}),
|
|
259
|
+
},
|
|
260
|
+
},
|
|
261
|
+
},
|
|
262
|
+
})
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
sendHookResponse(requestId: string, output: object = {}): void {
|
|
266
|
+
this.write({
|
|
267
|
+
type: 'control_response',
|
|
268
|
+
response: {
|
|
269
|
+
subtype: 'success',
|
|
270
|
+
request_id: requestId,
|
|
271
|
+
response: output,
|
|
272
|
+
},
|
|
273
|
+
})
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
sendSetPermissionMode(mode: 'default' | 'acceptEdits' | 'plan' | 'bypassPermissions'): void {
|
|
277
|
+
this.write({
|
|
278
|
+
type: 'control_request',
|
|
279
|
+
request_id: `mode-${++this.requestCounter}`,
|
|
280
|
+
request: { subtype: 'set_permission_mode', mode },
|
|
281
|
+
})
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// ── Lifecycle ───────────────────────────────────────────────────────
|
|
285
|
+
isAlive(): boolean { return this.alive }
|
|
286
|
+
|
|
287
|
+
async kill(timeoutMs = 5000): Promise<void> {
|
|
288
|
+
if (!this.alive) return
|
|
289
|
+
this.expectedExit = true
|
|
290
|
+
log(`claude-process: SIGTERM (timeout=${timeoutMs}ms)`)
|
|
291
|
+
try { this.proc.kill('SIGTERM') } catch {}
|
|
292
|
+
const start = Date.now()
|
|
293
|
+
while (this.alive && Date.now() - start < timeoutMs) {
|
|
294
|
+
await new Promise(r => setTimeout(r, 100))
|
|
295
|
+
}
|
|
296
|
+
if (this.alive) {
|
|
297
|
+
log('claude-process: SIGKILL (graceful timeout)')
|
|
298
|
+
try { this.proc.kill('SIGKILL') } catch {}
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read config.toml — minimal hand-rolled parser sufficient for the
|
|
3
|
+
* two-section, scalar-value-only schema we expect:
|
|
4
|
+
*
|
|
5
|
+
* [feishu]
|
|
6
|
+
* app_id = "cli_..."
|
|
7
|
+
* app_secret = "..."
|
|
8
|
+
*
|
|
9
|
+
* [runtime]
|
|
10
|
+
* projects_root = "~/" # optional, defaults to $HOME
|
|
11
|
+
*
|
|
12
|
+
* Loaded synchronously at import time; downstream modules read the
|
|
13
|
+
* exported `config` object directly.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { readFileSync } from 'node:fs'
|
|
17
|
+
import { homedir } from 'node:os'
|
|
18
|
+
import { CONFIG_FILE } from './paths'
|
|
19
|
+
|
|
20
|
+
export interface LodestarConfig {
|
|
21
|
+
feishu: {
|
|
22
|
+
app_id: string
|
|
23
|
+
app_secret: string
|
|
24
|
+
}
|
|
25
|
+
runtime: {
|
|
26
|
+
projects_root: string
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function expandTilde(v: string): string {
|
|
31
|
+
return v.replace(/^~(?=\/|$)/, homedir())
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function parseToml(text: string): Record<string, Record<string, string>> {
|
|
35
|
+
const out: Record<string, Record<string, string>> = { _: {} }
|
|
36
|
+
let section = '_'
|
|
37
|
+
for (const raw of text.split('\n')) {
|
|
38
|
+
const line = raw.replace(/(^|[^\\])#.*$/, '$1').trim()
|
|
39
|
+
if (!line) continue
|
|
40
|
+
const sec = line.match(/^\[([^\]]+)\]$/)
|
|
41
|
+
if (sec) {
|
|
42
|
+
section = sec[1].trim()
|
|
43
|
+
out[section] ??= {}
|
|
44
|
+
continue
|
|
45
|
+
}
|
|
46
|
+
const kv = line.match(/^([\w.-]+)\s*=\s*(.+)$/)
|
|
47
|
+
if (kv) {
|
|
48
|
+
let v = kv[2].trim()
|
|
49
|
+
if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) {
|
|
50
|
+
v = v.slice(1, -1)
|
|
51
|
+
}
|
|
52
|
+
out[section][kv[1]] = v
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return out
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function loadConfig(): LodestarConfig {
|
|
59
|
+
let raw: string
|
|
60
|
+
try {
|
|
61
|
+
raw = readFileSync(CONFIG_FILE, 'utf8')
|
|
62
|
+
} catch (e) {
|
|
63
|
+
process.stderr.write(
|
|
64
|
+
`lodestar: cannot read config at ${CONFIG_FILE}\n` +
|
|
65
|
+
` set LODESTAR_CONFIG=/path/to/config.toml to override, or create the file with:\n\n` +
|
|
66
|
+
` [feishu]\n app_id = "cli_xxx"\n app_secret = "xxx"\n\n`,
|
|
67
|
+
)
|
|
68
|
+
throw e
|
|
69
|
+
}
|
|
70
|
+
const t = parseToml(raw)
|
|
71
|
+
const appId = t.feishu?.app_id
|
|
72
|
+
const appSecret = t.feishu?.app_secret
|
|
73
|
+
if (!appId || !appSecret) {
|
|
74
|
+
throw new Error(`lodestar: ${CONFIG_FILE} is missing [feishu].app_id / [feishu].app_secret`)
|
|
75
|
+
}
|
|
76
|
+
const projectsRoot = expandTilde(t.runtime?.projects_root ?? homedir())
|
|
77
|
+
return {
|
|
78
|
+
feishu: { app_id: appId, app_secret: appSecret },
|
|
79
|
+
runtime: { projects_root: projectsRoot },
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export const config = loadConfig()
|