@leviyuan/lodestar 0.2.8 → 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.
@@ -1,395 +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
- /** Subset of the SDK's `usage` object — only the fields the console
48
- * panel actually shows. Anthropic adds new ones (server_tool_use,
49
- * service_tier, iterations, …) without breaking existing readers. */
50
- export interface ClaudeUsage {
51
- input_tokens?: number
52
- output_tokens?: number
53
- cache_creation_input_tokens?: number
54
- cache_read_input_tokens?: number
55
- }
56
-
57
- export interface ClaudeResultMeta {
58
- cost_usd: number | null
59
- duration_ms: number | null
60
- num_turns: number | null
61
- usage: ClaudeUsage | null
62
- }
63
-
64
- export interface HookCallbackRequest {
65
- request_id: string
66
- callback_id: string
67
- input: any
68
- tool_use_id?: string
69
- }
70
-
71
- export class ClaudeProcess extends EventEmitter {
72
- private proc: ChildProcessByStdio<Writable, Readable, Readable>
73
- private stdoutBuf = ''
74
- private stderrBuf = ''
75
- private requestCounter = 0
76
- private alive = true
77
- private expectedExit = false
78
- sessionId: string | null = null
79
- lastAssistantUuid: string | null = null
80
- /** Model id (`claude-opus-4-7`, …) from the most recent assistant
81
- * message — surfaced in the console panel so the user sees what's
82
- * actually running, not whatever default we'd assume. */
83
- lastModel: string | null = null
84
- /** Usage from the most recent `assistant` message — input_tokens +
85
- * cache_* on the *latest* message is the best proxy for current
86
- * context-window occupancy, since each assistant turn replays the
87
- * accumulated conversation. */
88
- lastUsage: ClaudeUsage | null = null
89
- /** Result-level metadata from the most recent `result` message —
90
- * per-turn cost / wallclock / num_turns. Cleared to null only on
91
- * spawn; persists across turns so the console panel can show "last
92
- * turn" stats even while idle. */
93
- lastResult: ClaudeResultMeta = {
94
- cost_usd: null, duration_ms: null, num_turns: null, usage: null,
95
- }
96
- /** Context-window capacity of the model that ran the latest turn —
97
- * lifted from `result.modelUsage[model].contextWindow` so we don't
98
- * have to hardcode `[1m]` vs stock variants. 200K is the safe
99
- * default if no result has landed yet (e.g. between spawn and the
100
- * first turn close). */
101
- lastContextWindow: number = 200_000
102
-
103
- constructor(opts: SpawnOpts) {
104
- super()
105
- const claudeBin = join(homedir(), '.local', 'bin', 'claude')
106
- const args = [
107
- '-p',
108
- '--input-format=stream-json',
109
- '--output-format=stream-json',
110
- '--verbose',
111
- '--permission-prompt-tool=stdio',
112
- `--permission-mode=${opts.permissionMode ?? 'default'}`,
113
- ]
114
- if (opts.model) args.push('--model', opts.model)
115
- if (opts.effort) args.push('--effort', opts.effort)
116
- if (opts.appendSystemPrompt) args.push('--append-system-prompt', opts.appendSystemPrompt)
117
- if (opts.resumeSessionId) {
118
- args.push('--resume', opts.resumeSessionId)
119
- if (opts.resumeAtUuid) args.push('--resume-session-at', opts.resumeAtUuid)
120
- }
121
-
122
- log(`claude-process: spawn ${claudeBin} (cwd=${opts.workDir})`)
123
- this.proc = spawn(claudeBin, args, {
124
- cwd: opts.workDir,
125
- stdio: ['pipe', 'pipe', 'pipe'],
126
- env: {
127
- ...process.env,
128
- NPM_CONFIG_LOGLEVEL: 'error',
129
- PATH: [
130
- join(homedir(), '.local', 'bin'),
131
- join(homedir(), '.bun', 'bin'),
132
- join(homedir(), '.local', 'npm-global', 'bin'),
133
- '/usr/local/bin', '/usr/bin', '/bin',
134
- ].join(':'),
135
- },
136
- }) as ChildProcessByStdio<Writable, Readable, Readable>
137
-
138
- this.proc.stdout.on('data', (chunk: Buffer) => this.onStdout(chunk))
139
- this.proc.stderr.on('data', (chunk: Buffer) => this.onStderr(chunk))
140
- this.proc.on('exit', (code, signal) => {
141
- this.alive = false
142
- log(`claude-process: exited code=${code} signal=${signal} expected=${this.expectedExit}`)
143
- this.emit('exit', { code, signal, expected: this.expectedExit })
144
- })
145
- this.proc.on('error', err => {
146
- log(`claude-process: spawn error: ${err}`)
147
- this.emit('error', err)
148
- })
149
- }
150
-
151
- // ── Stream parsers ─────────────────────────────────────────────────
152
- private onStdout(chunk: Buffer): void {
153
- this.stdoutBuf += chunk.toString()
154
- let nl: number
155
- while ((nl = this.stdoutBuf.indexOf('\n')) >= 0) {
156
- const line = this.stdoutBuf.slice(0, nl).trim()
157
- this.stdoutBuf = this.stdoutBuf.slice(nl + 1)
158
- if (!line) continue
159
- try {
160
- const msg = JSON.parse(line)
161
- this.handleMessage(msg)
162
- } catch (e) {
163
- log(`claude-process: bad json: ${line.slice(0, 200)} (${e})`)
164
- }
165
- }
166
- }
167
-
168
- private onStderr(chunk: Buffer): void {
169
- this.stderrBuf += chunk.toString()
170
- let nl: number
171
- while ((nl = this.stderrBuf.indexOf('\n')) >= 0) {
172
- const line = this.stderrBuf.slice(0, nl)
173
- this.stderrBuf = this.stderrBuf.slice(nl + 1)
174
- if (line.trim()) log(`claude-process[stderr]: ${line}`)
175
- }
176
- }
177
-
178
- private handleMessage(msg: any): void {
179
- const type = msg.type
180
- if (type === 'system' && msg.subtype === 'init') {
181
- this.sessionId = msg.session_id ?? null
182
- log(`claude-process: session=${this.sessionId}`)
183
- this.emit('init', msg)
184
- return
185
- }
186
- if (type === 'control_request') {
187
- const req = msg.request
188
- if (req?.subtype === 'can_use_tool') {
189
- this.emit('can_use_tool', {
190
- request_id: msg.request_id,
191
- tool_name: req.tool_name,
192
- input: req.input,
193
- permission_suggestions: req.permission_suggestions,
194
- blocked_paths: req.blocked_paths,
195
- tool_use_id: req.tool_use_id,
196
- } as CanUseToolRequest)
197
- return
198
- }
199
- if (req?.subtype === 'hook_callback') {
200
- this.emit('hook_callback', {
201
- request_id: msg.request_id,
202
- callback_id: req.callback_id,
203
- input: req.input,
204
- tool_use_id: req.tool_use_id,
205
- } as HookCallbackRequest)
206
- return
207
- }
208
- log(`claude-process: unknown control_request subtype=${req?.subtype}`)
209
- return
210
- }
211
- if (type === 'control_response') {
212
- this.emit('control_response', msg)
213
- return
214
- }
215
- if (type === 'assistant') {
216
- const content = msg.message?.content ?? []
217
- for (const block of content) {
218
- if (block.type === 'text' && typeof block.text === 'string') {
219
- this.emit('assistant_text', { uuid: msg.uuid, text: block.text })
220
- } else if (block.type === 'tool_use') {
221
- this.emit('tool_use', { uuid: msg.uuid, id: block.id, name: block.name, input: block.input })
222
- } else if (block.type === 'thinking' && typeof block.thinking === 'string') {
223
- this.emit('thinking', { uuid: msg.uuid, text: block.thinking })
224
- }
225
- }
226
- if (msg.message?.usage) this.lastUsage = msg.message.usage as ClaudeUsage
227
- if (typeof msg.message?.model === 'string') this.lastModel = msg.message.model
228
- if (msg.uuid) this.lastAssistantUuid = msg.uuid
229
- return
230
- }
231
- if (type === 'user') {
232
- const content = msg.message?.content ?? []
233
- for (const block of content) {
234
- if (block.type === 'tool_result') {
235
- this.emit('tool_result', {
236
- tool_use_id: block.tool_use_id,
237
- content: block.content,
238
- is_error: block.is_error ?? false,
239
- })
240
- }
241
- }
242
- return
243
- }
244
- if (type === 'result') {
245
- this.lastResult = {
246
- cost_usd: typeof msg.total_cost_usd === 'number' ? msg.total_cost_usd : null,
247
- duration_ms: typeof msg.duration_ms === 'number' ? msg.duration_ms : null,
248
- num_turns: typeof msg.num_turns === 'number' ? msg.num_turns : null,
249
- usage: msg.usage ?? null,
250
- }
251
- // modelUsage maps "<model id>" → { contextWindow, maxOutputTokens, … }.
252
- // For mixed-model runs the SDK reports one entry per model used in
253
- // the turn; we take the one matching `lastModel` (the assistant's
254
- // latest model id) and fall back to any single entry if it's the
255
- // only one — covers the common single-model case.
256
- const mu = msg.modelUsage
257
- if (mu && typeof mu === 'object') {
258
- const entry = (this.lastModel && mu[this.lastModel])
259
- || (Object.keys(mu).length === 1 ? mu[Object.keys(mu)[0]!] : null)
260
- if (entry && typeof entry.contextWindow === 'number' && entry.contextWindow > 0) {
261
- this.lastContextWindow = entry.contextWindow
262
- }
263
- }
264
- this.emit('result', msg)
265
- return
266
- }
267
- this.emit('raw', msg)
268
- }
269
-
270
- private write(obj: object): void {
271
- if (!this.alive) {
272
- log(`claude-process: write to dead process: ${JSON.stringify(obj).slice(0, 200)}`)
273
- return
274
- }
275
- try {
276
- this.proc.stdin.write(JSON.stringify(obj) + '\n')
277
- } catch (e) { log(`claude-process: stdin write failed: ${e}`) }
278
- }
279
-
280
- // ── Outbound control ────────────────────────────────────────────────
281
- sendInitialize(hooks: Record<string, any> = {}): void {
282
- this.write({
283
- type: 'control_request',
284
- request_id: `init-${++this.requestCounter}`,
285
- request: { subtype: 'initialize', hooks },
286
- })
287
- }
288
-
289
- sendUserText(text: string, files: string[] = []): void {
290
- const fileHints = files.length ? files.map(f => `[file: ${f}]`).join(' ') + '\n\n' : ''
291
- this.write({
292
- type: 'user',
293
- message: {
294
- role: 'user',
295
- content: [{ type: 'text', text: fileHints + text }],
296
- },
297
- })
298
- }
299
-
300
- sendInterrupt(): void {
301
- this.write({
302
- type: 'control_request',
303
- request_id: `int-${++this.requestCounter}`,
304
- request: { subtype: 'interrupt' },
305
- })
306
- }
307
-
308
- /** Response schema for `can_use_tool` control_request (NOT the
309
- * PreToolUse-hook output shape that lives elsewhere in the SDK).
310
- * allow → { behavior: 'allow', updatedInput: <record> }
311
- * deny → { behavior: 'deny', message: <string> }
312
- * Sending the wrong shape gets Zod-rejected on the Claude side and
313
- * the decision never lands — the card visually flips to "resolved"
314
- * but Claude immediately re-fires the same can_use_tool, looking to
315
- * the user like a "flash back to original" bug. */
316
- sendPermissionResponse(
317
- requestId: string,
318
- decision: 'allow' | 'deny',
319
- payload?: { updatedInput?: Record<string, unknown>; denyMessage?: string },
320
- ): void {
321
- const response = decision === 'allow'
322
- ? { behavior: 'allow' as const, updatedInput: payload?.updatedInput ?? {} }
323
- : { behavior: 'deny' as const, message: payload?.denyMessage ?? 'denied by user' }
324
- this.write({
325
- type: 'control_response',
326
- response: {
327
- subtype: 'success',
328
- request_id: requestId,
329
- response,
330
- },
331
- })
332
- }
333
-
334
- /** Feed a `tool_result` back to Claude as if a tool finished — used
335
- * by the daemon when WE (the client) own the tool's execution side,
336
- * e.g. AskUserQuestion: the SDK emits `tool_use` and waits for the
337
- * client to render UI, collect a choice, and supply the result here.
338
- * `content` is whatever the tool's contract says — for
339
- * AskUserQuestion this is a JSON string `{"answers": {...}}`.
340
- *
341
- * NOTE: this is the same `{type:'user'}` envelope as `sendUserText`,
342
- * just with a `tool_result` content block instead of plain text. The
343
- * SDK demultiplexes by `tool_use_id`. */
344
- sendToolResult(toolUseId: string, content: string, isError = false): void {
345
- this.write({
346
- type: 'user',
347
- message: {
348
- role: 'user',
349
- content: [{
350
- type: 'tool_result',
351
- tool_use_id: toolUseId,
352
- content,
353
- ...(isError ? { is_error: true } : {}),
354
- }],
355
- },
356
- })
357
- }
358
-
359
- sendHookResponse(requestId: string, output: object = {}): void {
360
- this.write({
361
- type: 'control_response',
362
- response: {
363
- subtype: 'success',
364
- request_id: requestId,
365
- response: output,
366
- },
367
- })
368
- }
369
-
370
- sendSetPermissionMode(mode: 'default' | 'acceptEdits' | 'plan' | 'bypassPermissions'): void {
371
- this.write({
372
- type: 'control_request',
373
- request_id: `mode-${++this.requestCounter}`,
374
- request: { subtype: 'set_permission_mode', mode },
375
- })
376
- }
377
-
378
- // ── Lifecycle ───────────────────────────────────────────────────────
379
- isAlive(): boolean { return this.alive }
380
-
381
- async kill(timeoutMs = 5000): Promise<void> {
382
- if (!this.alive) return
383
- this.expectedExit = true
384
- log(`claude-process: SIGTERM (timeout=${timeoutMs}ms)`)
385
- try { this.proc.kill('SIGTERM') } catch {}
386
- const start = Date.now()
387
- while (this.alive && Date.now() - start < timeoutMs) {
388
- await new Promise(r => setTimeout(r, 100))
389
- }
390
- if (this.alive) {
391
- log('claude-process: SIGKILL (graceful timeout)')
392
- try { this.proc.kill('SIGKILL') } catch {}
393
- }
394
- }
395
- }
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()