@leviyuan/lodestar 0.1.0 → 2.0.14

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.
@@ -1,301 +0,0 @@
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 DELETED
@@ -1,83 +0,0 @@
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()