@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.
- package/README.md +76 -67
- package/cli.ts +176 -0
- package/config.ts +135 -0
- package/daemon.ts +1080 -144
- package/email-worker.ts +534 -0
- package/env-bootstrap.ts +7 -0
- package/feishu-mcp.ts +482 -0
- package/package.json +36 -37
- package/runtime-api.ts +569 -0
- package/scripts/runtime-thread.sh +91 -0
- package/status-dashboard.ts +733 -0
- package/src/cardkit.ts +0 -215
- package/src/cards.ts +0 -304
- package/src/claude-process.ts +0 -301
- package/src/config.ts +0 -83
- package/src/feishu.ts +0 -365
- package/src/instructions.ts +0 -22
- package/src/log.ts +0 -11
- package/src/paths.ts +0 -41
- package/src/session.ts +0 -447
package/src/claude-process.ts
DELETED
|
@@ -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()
|