@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.
@@ -1,417 +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
- /** SDK `result.subtype` — `success` on natural end_turn,
63
- * `error_max_turns` if --max-turns was reached, `error_during_execution`
64
- * for API-side failures (rate limit / 5xx / context overflow / refusal).
65
- * Session.wireProc uses this to drive auto-retry + suppress phone push
66
- * on aborted turns. */
67
- subtype: string | null
68
- /** Mirror of `subtype !== 'success'`; cached so callers don't have to
69
- * re-derive it from the (open-ended) subtype string. */
70
- is_error: boolean
71
- }
72
-
73
- export interface HookCallbackRequest {
74
- request_id: string
75
- callback_id: string
76
- input: any
77
- tool_use_id?: string
78
- }
79
-
80
- export class ClaudeProcess extends EventEmitter {
81
- private proc: ChildProcessByStdio<Writable, Readable, Readable>
82
- private stdoutBuf = ''
83
- private stderrBuf = ''
84
- private requestCounter = 0
85
- private alive = true
86
- private expectedExit = false
87
- sessionId: string | null = null
88
- lastAssistantUuid: string | null = null
89
- /** Model id (`claude-opus-4-7`, …) from the most recent assistant
90
- * message — surfaced in the console panel so the user sees what's
91
- * actually running, not whatever default we'd assume. */
92
- lastModel: string | null = null
93
- /** Usage from the most recent `assistant` message — input_tokens +
94
- * cache_* on the *latest* message is the best proxy for current
95
- * context-window occupancy, since each assistant turn replays the
96
- * accumulated conversation. */
97
- lastUsage: ClaudeUsage | null = null
98
- /** Result-level metadata from the most recent `result` message —
99
- * per-turn cost / wallclock / num_turns. Cleared to null only on
100
- * spawn; persists across turns so the console panel can show "last
101
- * turn" stats even while idle. */
102
- lastResult: ClaudeResultMeta = {
103
- cost_usd: null, duration_ms: null, num_turns: null, usage: null,
104
- subtype: null, is_error: false,
105
- }
106
- /** Context-window capacity of the model that ran the latest turn —
107
- * lifted from `result.modelUsage[model].contextWindow` so we don't
108
- * have to hardcode `[1m]` vs stock variants. `null` until the first
109
- * `result` event lands (spawn → first turn close gap). Callers that
110
- * render a percentage MUST treat null as "unknown" and drop the %
111
- * rather than substituting a default — past life of this field was a
112
- * 200K constant default which made fresh-spawn `hi` panels lie about
113
- * the limit on this project's opus-4-7[1m] (1M) pin. (no_fallbacks
114
- * rule applies: surface absence, don't fabricate.) */
115
- lastContextWindow: number | null = null
116
-
117
- constructor(opts: SpawnOpts) {
118
- super()
119
- const claudeBin = join(homedir(), '.local', 'bin', 'claude')
120
- const args = [
121
- '-p',
122
- '--input-format=stream-json',
123
- '--output-format=stream-json',
124
- '--verbose',
125
- '--permission-prompt-tool=stdio',
126
- `--permission-mode=${opts.permissionMode ?? 'default'}`,
127
- ]
128
- if (opts.model) args.push('--model', opts.model)
129
- if (opts.effort) args.push('--effort', opts.effort)
130
- if (opts.appendSystemPrompt) args.push('--append-system-prompt', opts.appendSystemPrompt)
131
- if (opts.resumeSessionId) {
132
- args.push('--resume', opts.resumeSessionId)
133
- if (opts.resumeAtUuid) args.push('--resume-session-at', opts.resumeAtUuid)
134
- }
135
-
136
- log(`claude-process: spawn ${claudeBin} (cwd=${opts.workDir})`)
137
- this.proc = spawn(claudeBin, args, {
138
- cwd: opts.workDir,
139
- stdio: ['pipe', 'pipe', 'pipe'],
140
- env: {
141
- ...process.env,
142
- NPM_CONFIG_LOGLEVEL: 'error',
143
- PATH: [
144
- join(homedir(), '.local', 'bin'),
145
- join(homedir(), '.bun', 'bin'),
146
- join(homedir(), '.local', 'npm-global', 'bin'),
147
- '/usr/local/bin', '/usr/bin', '/bin',
148
- ].join(':'),
149
- },
150
- }) as ChildProcessByStdio<Writable, Readable, Readable>
151
-
152
- this.proc.stdout.on('data', (chunk: Buffer) => this.onStdout(chunk))
153
- this.proc.stderr.on('data', (chunk: Buffer) => this.onStderr(chunk))
154
- this.proc.on('exit', (code, signal) => {
155
- this.alive = false
156
- log(`claude-process: exited code=${code} signal=${signal} expected=${this.expectedExit}`)
157
- this.emit('exit', { code, signal, expected: this.expectedExit })
158
- })
159
- this.proc.on('error', err => {
160
- log(`claude-process: spawn error: ${err}`)
161
- this.emit('error', err)
162
- })
163
- }
164
-
165
- // ── Stream parsers ─────────────────────────────────────────────────
166
- private onStdout(chunk: Buffer): void {
167
- this.stdoutBuf += chunk.toString()
168
- let nl: number
169
- while ((nl = this.stdoutBuf.indexOf('\n')) >= 0) {
170
- const line = this.stdoutBuf.slice(0, nl).trim()
171
- this.stdoutBuf = this.stdoutBuf.slice(nl + 1)
172
- if (!line) continue
173
- try {
174
- const msg = JSON.parse(line)
175
- this.handleMessage(msg)
176
- } catch (e) {
177
- log(`claude-process: bad json: ${line.slice(0, 200)} (${e})`)
178
- }
179
- }
180
- }
181
-
182
- private onStderr(chunk: Buffer): void {
183
- this.stderrBuf += chunk.toString()
184
- let nl: number
185
- while ((nl = this.stderrBuf.indexOf('\n')) >= 0) {
186
- const line = this.stderrBuf.slice(0, nl)
187
- this.stderrBuf = this.stderrBuf.slice(nl + 1)
188
- if (line.trim()) log(`claude-process[stderr]: ${line}`)
189
- }
190
- }
191
-
192
- private handleMessage(msg: any): void {
193
- const type = msg.type
194
- if (type === 'system' && msg.subtype === 'init') {
195
- this.sessionId = msg.session_id ?? null
196
- log(`claude-process: session=${this.sessionId}`)
197
- this.emit('init', msg)
198
- return
199
- }
200
- if (type === 'control_request') {
201
- const req = msg.request
202
- if (req?.subtype === 'can_use_tool') {
203
- this.emit('can_use_tool', {
204
- request_id: msg.request_id,
205
- tool_name: req.tool_name,
206
- input: req.input,
207
- permission_suggestions: req.permission_suggestions,
208
- blocked_paths: req.blocked_paths,
209
- tool_use_id: req.tool_use_id,
210
- } as CanUseToolRequest)
211
- return
212
- }
213
- if (req?.subtype === 'hook_callback') {
214
- this.emit('hook_callback', {
215
- request_id: msg.request_id,
216
- callback_id: req.callback_id,
217
- input: req.input,
218
- tool_use_id: req.tool_use_id,
219
- } as HookCallbackRequest)
220
- return
221
- }
222
- log(`claude-process: unknown control_request subtype=${req?.subtype}`)
223
- return
224
- }
225
- if (type === 'control_response') {
226
- this.emit('control_response', msg)
227
- return
228
- }
229
- if (type === 'assistant') {
230
- const content = msg.message?.content ?? []
231
- for (const block of content) {
232
- if (block.type === 'text' && typeof block.text === 'string') {
233
- this.emit('assistant_text', { uuid: msg.uuid, text: block.text })
234
- } else if (block.type === 'tool_use') {
235
- this.emit('tool_use', { uuid: msg.uuid, id: block.id, name: block.name, input: block.input })
236
- } else if (block.type === 'thinking' && typeof block.thinking === 'string') {
237
- this.emit('thinking', { uuid: msg.uuid, text: block.thinking })
238
- }
239
- }
240
- if (msg.message?.usage) this.lastUsage = msg.message.usage as ClaudeUsage
241
- if (typeof msg.message?.model === 'string') this.lastModel = msg.message.model
242
- if (msg.uuid) this.lastAssistantUuid = msg.uuid
243
- return
244
- }
245
- if (type === 'user') {
246
- const content = msg.message?.content ?? []
247
- for (const block of content) {
248
- if (block.type === 'tool_result') {
249
- this.emit('tool_result', {
250
- tool_use_id: block.tool_use_id,
251
- content: block.content,
252
- is_error: block.is_error ?? false,
253
- })
254
- }
255
- }
256
- return
257
- }
258
- if (type === 'result') {
259
- const subtype = typeof msg.subtype === 'string' ? msg.subtype : null
260
- this.lastResult = {
261
- cost_usd: typeof msg.total_cost_usd === 'number' ? msg.total_cost_usd : null,
262
- duration_ms: typeof msg.duration_ms === 'number' ? msg.duration_ms : null,
263
- num_turns: typeof msg.num_turns === 'number' ? msg.num_turns : null,
264
- usage: msg.usage ?? null,
265
- subtype,
266
- // Trust the SDK's own bool when present; otherwise derive from
267
- // subtype so a future error_* variant we haven't seen still flags
268
- // as error.
269
- is_error: typeof msg.is_error === 'boolean'
270
- ? msg.is_error
271
- : (subtype !== null && subtype !== 'success'),
272
- }
273
- // modelUsage maps "<model id>" → { contextWindow, maxOutputTokens, … }.
274
- // For mixed-model runs the SDK reports one entry per model used in
275
- // the turn; we take the one matching `lastModel` (the assistant's
276
- // latest model id) and fall back to any single entry if it's the
277
- // only one — covers the common single-model case.
278
- const mu = msg.modelUsage
279
- if (mu && typeof mu === 'object') {
280
- const entry = (this.lastModel && mu[this.lastModel])
281
- || (Object.keys(mu).length === 1 ? mu[Object.keys(mu)[0]!] : null)
282
- if (entry && typeof entry.contextWindow === 'number' && entry.contextWindow > 0) {
283
- this.lastContextWindow = entry.contextWindow
284
- }
285
- }
286
- this.emit('result', msg)
287
- return
288
- }
289
- this.emit('raw', msg)
290
- }
291
-
292
- private write(obj: object): void {
293
- if (!this.alive) {
294
- log(`claude-process: write to dead process: ${JSON.stringify(obj).slice(0, 200)}`)
295
- return
296
- }
297
- try {
298
- this.proc.stdin.write(JSON.stringify(obj) + '\n')
299
- } catch (e) { log(`claude-process: stdin write failed: ${e}`) }
300
- }
301
-
302
- // ── Outbound control ────────────────────────────────────────────────
303
- sendInitialize(hooks: Record<string, any> = {}): void {
304
- this.write({
305
- type: 'control_request',
306
- request_id: `init-${++this.requestCounter}`,
307
- request: { subtype: 'initialize', hooks },
308
- })
309
- }
310
-
311
- sendUserText(text: string, files: string[] = []): void {
312
- const fileHints = files.length ? files.map(f => `[file: ${f}]`).join(' ') + '\n\n' : ''
313
- this.write({
314
- type: 'user',
315
- message: {
316
- role: 'user',
317
- content: [{ type: 'text', text: fileHints + text }],
318
- },
319
- })
320
- }
321
-
322
- sendInterrupt(): void {
323
- this.write({
324
- type: 'control_request',
325
- request_id: `int-${++this.requestCounter}`,
326
- request: { subtype: 'interrupt' },
327
- })
328
- }
329
-
330
- /** Response schema for `can_use_tool` control_request (NOT the
331
- * PreToolUse-hook output shape that lives elsewhere in the SDK).
332
- * allow → { behavior: 'allow', updatedInput: <record> }
333
- * deny → { behavior: 'deny', message: <string> }
334
- * Sending the wrong shape gets Zod-rejected on the Claude side and
335
- * the decision never lands — the card visually flips to "resolved"
336
- * but Claude immediately re-fires the same can_use_tool, looking to
337
- * the user like a "flash back to original" bug. */
338
- sendPermissionResponse(
339
- requestId: string,
340
- decision: 'allow' | 'deny',
341
- payload?: { updatedInput?: Record<string, unknown>; denyMessage?: string },
342
- ): void {
343
- const response = decision === 'allow'
344
- ? { behavior: 'allow' as const, updatedInput: payload?.updatedInput ?? {} }
345
- : { behavior: 'deny' as const, message: payload?.denyMessage ?? 'denied by user' }
346
- this.write({
347
- type: 'control_response',
348
- response: {
349
- subtype: 'success',
350
- request_id: requestId,
351
- response,
352
- },
353
- })
354
- }
355
-
356
- /** Feed a `tool_result` back to Claude as if a tool finished — used
357
- * by the daemon when WE (the client) own the tool's execution side,
358
- * e.g. AskUserQuestion: the SDK emits `tool_use` and waits for the
359
- * client to render UI, collect a choice, and supply the result here.
360
- * `content` is whatever the tool's contract says — for
361
- * AskUserQuestion this is a JSON string `{"answers": {...}}`.
362
- *
363
- * NOTE: this is the same `{type:'user'}` envelope as `sendUserText`,
364
- * just with a `tool_result` content block instead of plain text. The
365
- * SDK demultiplexes by `tool_use_id`. */
366
- sendToolResult(toolUseId: string, content: string, isError = false): void {
367
- this.write({
368
- type: 'user',
369
- message: {
370
- role: 'user',
371
- content: [{
372
- type: 'tool_result',
373
- tool_use_id: toolUseId,
374
- content,
375
- ...(isError ? { is_error: true } : {}),
376
- }],
377
- },
378
- })
379
- }
380
-
381
- sendHookResponse(requestId: string, output: object = {}): void {
382
- this.write({
383
- type: 'control_response',
384
- response: {
385
- subtype: 'success',
386
- request_id: requestId,
387
- response: output,
388
- },
389
- })
390
- }
391
-
392
- sendSetPermissionMode(mode: 'default' | 'acceptEdits' | 'plan' | 'bypassPermissions'): void {
393
- this.write({
394
- type: 'control_request',
395
- request_id: `mode-${++this.requestCounter}`,
396
- request: { subtype: 'set_permission_mode', mode },
397
- })
398
- }
399
-
400
- // ── Lifecycle ───────────────────────────────────────────────────────
401
- isAlive(): boolean { return this.alive }
402
-
403
- async kill(timeoutMs = 5000): Promise<void> {
404
- if (!this.alive) return
405
- this.expectedExit = true
406
- log(`claude-process: SIGTERM (timeout=${timeoutMs}ms)`)
407
- try { this.proc.kill('SIGTERM') } catch {}
408
- const start = Date.now()
409
- while (this.alive && Date.now() - start < timeoutMs) {
410
- await new Promise(r => setTimeout(r, 100))
411
- }
412
- if (this.alive) {
413
- log('claude-process: SIGKILL (graceful timeout)')
414
- try { this.proc.kill('SIGKILL') } catch {}
415
- }
416
- }
417
- }
package/src/config.ts DELETED
@@ -1,98 +0,0 @@
1
- /**
2
- * Read config.toml — minimal hand-rolled parser sufficient for the
3
- * three-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
- * [notify] # all optional
13
- * bind = "127.0.0.1" # default 127.0.0.1 (loopback only)
14
- * port = 9876 # default 9876
15
- *
16
- * Loaded synchronously at import time; downstream modules read the
17
- * exported `config` object directly.
18
- */
19
-
20
- import { readFileSync } from 'node:fs'
21
- import { homedir } from 'node:os'
22
- import { CONFIG_FILE } from './paths'
23
-
24
- export interface LodestarConfig {
25
- feishu: {
26
- app_id: string
27
- app_secret: string
28
- }
29
- runtime: {
30
- projects_root: string
31
- }
32
- notify: {
33
- bind: string
34
- port: number
35
- }
36
- }
37
-
38
- function expandTilde(v: string): string {
39
- return v.replace(/^~(?=\/|$)/, homedir())
40
- }
41
-
42
- function parseToml(text: string): Record<string, Record<string, string>> {
43
- const out: Record<string, Record<string, string>> = { _: {} }
44
- let section = '_'
45
- for (const raw of text.split('\n')) {
46
- const line = raw.replace(/(^|[^\\])#.*$/, '$1').trim()
47
- if (!line) continue
48
- const sec = line.match(/^\[([^\]]+)\]$/)
49
- if (sec) {
50
- section = sec[1].trim()
51
- out[section] ??= {}
52
- continue
53
- }
54
- const kv = line.match(/^([\w.-]+)\s*=\s*(.+)$/)
55
- if (kv) {
56
- let v = kv[2].trim()
57
- if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) {
58
- v = v.slice(1, -1)
59
- }
60
- out[section][kv[1]] = v
61
- }
62
- }
63
- return out
64
- }
65
-
66
- function loadConfig(): LodestarConfig {
67
- let raw: string
68
- try {
69
- raw = readFileSync(CONFIG_FILE, 'utf8')
70
- } catch (e) {
71
- process.stderr.write(
72
- `lodestar: cannot read config at ${CONFIG_FILE}\n` +
73
- ` set LODESTAR_CONFIG=/path/to/config.toml to override, or create the file with:\n\n` +
74
- ` [feishu]\n app_id = "cli_xxx"\n app_secret = "xxx"\n\n`,
75
- )
76
- throw e
77
- }
78
- const t = parseToml(raw)
79
- const appId = t.feishu?.app_id
80
- const appSecret = t.feishu?.app_secret
81
- if (!appId || !appSecret) {
82
- throw new Error(`lodestar: ${CONFIG_FILE} is missing [feishu].app_id / [feishu].app_secret`)
83
- }
84
- const projectsRoot = expandTilde(t.runtime?.projects_root ?? homedir())
85
- const notifyBind = t.notify?.bind ?? '127.0.0.1'
86
- const notifyPortRaw = t.notify?.port ?? '9876'
87
- const notifyPort = Number.parseInt(notifyPortRaw, 10)
88
- if (!Number.isFinite(notifyPort) || notifyPort <= 0 || notifyPort > 65535) {
89
- throw new Error(`lodestar: [notify].port must be 1..65535, got "${notifyPortRaw}"`)
90
- }
91
- return {
92
- feishu: { app_id: appId, app_secret: appSecret },
93
- runtime: { projects_root: projectsRoot },
94
- notify: { bind: notifyBind, port: notifyPort },
95
- }
96
- }
97
-
98
- export const config = loadConfig()