macroclaw 0.32.0 → 0.33.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/src/claude.ts CHANGED
@@ -34,14 +34,6 @@ export interface QueryResult<T> {
34
34
  cost?: string;
35
35
  }
36
36
 
37
- /** A running query — result is always deferred */
38
- export interface RunningQuery<T> {
39
- sessionId: string;
40
- startedAt: Date;
41
- result: Promise<QueryResult<T>>;
42
- kill: () => Promise<void>;
43
- }
44
-
45
37
  /** Claude process exited with non-zero code */
46
38
  export class QueryProcessError extends Error {
47
39
  constructor(
@@ -69,15 +61,166 @@ export class QueryValidationError extends Error {
69
61
  }
70
62
  }
71
63
 
64
+ // --- ClaudeProcess ---
65
+
66
+ export type ProcessState = "idle" | "busy" | "dead";
67
+
68
+ /** Minimal interface for the underlying process — matches Bun.spawn output when stdin/stdout/stderr are "pipe" */
69
+ export interface RawProcess {
70
+ stdin: { write(data: string): void; flush(): void; end(): void };
71
+ stdout: ReadableStream<Uint8Array>;
72
+ stderr: ReadableStream<Uint8Array>;
73
+ exited: Promise<number>;
74
+ kill(): void;
75
+ }
76
+
77
+ type StreamReader = { read(): Promise<{ done: boolean; value?: Uint8Array }>; cancel(reason?: unknown): Promise<void> };
78
+
79
+ export class ClaudeProcess<T = unknown> {
80
+ readonly sessionId: string;
81
+ readonly startedAt: Date;
82
+ #state: ProcessState = "idle";
83
+ #proc: RawProcess;
84
+ #resultType: ResultType;
85
+ #reader: StreamReader;
86
+ #lines: AsyncGenerator<string>;
87
+
88
+ constructor(proc: RawProcess, sessionId: string, resultType: ResultType) {
89
+ this.#proc = proc;
90
+ this.sessionId = sessionId;
91
+ this.#resultType = resultType;
92
+ this.startedAt = new Date();
93
+ this.#reader = proc.stdout.getReader();
94
+ this.#lines = ClaudeProcess.#readLines(this.#reader);
95
+
96
+ proc.exited.then((code) => {
97
+ if (this.#state !== "dead") {
98
+ log.warn({ sessionId, exitCode: code }, "Process exited unexpectedly");
99
+ this.#state = "dead";
100
+ this.#reader.cancel().catch(() => {});
101
+ }
102
+ });
103
+ }
104
+
105
+ get state(): ProcessState {
106
+ return this.#state;
107
+ }
108
+
109
+ async send(prompt: string): Promise<QueryResult<T>> {
110
+ if (this.#state !== "idle") {
111
+ throw new Error(`Cannot send: process is ${this.#state}`);
112
+ }
113
+ this.#state = "busy";
114
+
115
+ const msg = `${JSON.stringify({ type: "user", message: { role: "user", content: prompt } })}\n`;
116
+
117
+ try {
118
+ this.#proc.stdin.write(msg);
119
+ this.#proc.stdin.flush();
120
+ } catch (err) {
121
+ this.#state = "dead";
122
+ throw new QueryProcessError(-1, `Failed to write to stdin: ${err}`);
123
+ }
124
+
125
+ log.debug({ sessionId: this.sessionId, promptLen: prompt.length }, "Sent to Claude");
126
+
127
+ while (true) {
128
+ const { done, value: line } = await this.#lines.next();
129
+ if (done) {
130
+ this.#state = "dead";
131
+ const exitCode = await this.#proc.exited;
132
+ const stderr = await new Response(this.#proc.stderr).text();
133
+ log.error({ exitCode, stderr: stderr.slice(0, 200) }, "Claude process ended unexpectedly");
134
+ throw new QueryProcessError(exitCode, stderr);
135
+ }
136
+
137
+ let parsed: Record<string, unknown>;
138
+ try {
139
+ parsed = JSON.parse(line) as Record<string, unknown>;
140
+ } catch {
141
+ log.debug({ line: line.slice(0, 100) }, "Skipping non-JSON line");
142
+ continue;
143
+ }
144
+
145
+ if (parsed.type !== "result") {
146
+ const thinking = ClaudeProcess.#extractThinking(parsed);
147
+ if (thinking) log.debug({ thinking }, "Thinking");
148
+ continue;
149
+ }
150
+
151
+ this.#state = "idle";
152
+ return this.#parseResult(parsed);
153
+ }
154
+ }
155
+
156
+ async kill(): Promise<void> {
157
+ if (this.#state === "dead") return;
158
+ this.#state = "dead";
159
+ this.#reader.cancel().catch(() => {});
160
+ this.#proc.kill();
161
+ await this.#proc.exited;
162
+ }
163
+
164
+ #parseResult(envelope: Record<string, unknown>): QueryResult<T> {
165
+ const sid = typeof envelope.session_id === "string" ? envelope.session_id : this.sessionId;
166
+ const durationMs = typeof envelope.duration_ms === "number" ? envelope.duration_ms : undefined;
167
+ const costUsd = typeof envelope.total_cost_usd === "number" ? envelope.total_cost_usd : undefined;
168
+ const duration = durationMs ? `${(durationMs / 1000).toFixed(1)}s` : undefined;
169
+ const cost = costUsd ? `$${costUsd.toFixed(4)}` : undefined;
170
+
171
+ let value: T;
172
+ if (this.#resultType.type === "text") {
173
+ value = (typeof envelope.result === "string" ? envelope.result : "") as T;
174
+ } else {
175
+ const raw = envelope.structured_output;
176
+ try {
177
+ value = (this.#resultType as { type: "object"; schema: z.ZodType }).schema.parse(raw) as T;
178
+ } catch (err) {
179
+ throw new QueryValidationError(raw, err);
180
+ }
181
+ }
182
+
183
+ log.debug({ duration, cost, sessionId: sid }, "Claude response received");
184
+ return { value, sessionId: sid, duration, cost };
185
+ }
186
+
187
+ static #extractThinking(event: Record<string, unknown>): string | undefined {
188
+ if (event.type !== "assistant") return undefined;
189
+ const msg = event.message as Record<string, unknown> | undefined;
190
+ const content = msg?.content as Array<Record<string, unknown>> | undefined;
191
+ if (!Array.isArray(content)) return undefined;
192
+ const block = content.find((c) => c.type === "thinking");
193
+ return typeof block?.thinking === "string" ? block.thinking : undefined;
194
+ }
195
+
196
+ static async *#readLines(reader: StreamReader): AsyncGenerator<string> {
197
+ const decoder = new TextDecoder();
198
+ let buffer = "";
199
+ while (true) {
200
+ const { done, value } = await reader.read();
201
+ if (done) break;
202
+ buffer += decoder.decode(value, { stream: true });
203
+ const lines = buffer.split("\n");
204
+ buffer = lines.pop() ?? "";
205
+ for (const line of lines) {
206
+ if (line.trim()) yield line;
207
+ }
208
+ }
209
+ if (buffer.trim()) yield buffer;
210
+ }
211
+ }
212
+
213
+ // --- Claude factory ---
214
+
72
215
  type SessionMode =
73
216
  | { kind: "new" }
74
217
  | { kind: "resume"; sessionId: string }
75
218
  | { kind: "fork"; sessionId: string };
76
219
 
77
220
  export class Claude {
78
- #workspace: string;
79
- #model?: string;
80
- #systemPrompt?: string;
221
+ readonly #workspace: string;
222
+ readonly #model?: string;
223
+ readonly #systemPrompt?: string;
81
224
 
82
225
  constructor(config: ClaudeConfig) {
83
226
  this.#workspace = config.workspace;
@@ -85,19 +228,19 @@ export class Claude {
85
228
  this.#systemPrompt = config.systemPrompt;
86
229
  }
87
230
 
88
- newSession<R extends ResultType>(prompt: string, resultType: R, options?: QueryOptions): RunningQuery<InferResult<R>> {
89
- return this.#spawn({ kind: "new" }, prompt, resultType, options);
231
+ newSession<R extends ResultType>(resultType: R, options?: QueryOptions): ClaudeProcess<InferResult<R>> {
232
+ return this.#spawn({ kind: "new" }, resultType, options);
90
233
  }
91
234
 
92
- resumeSession<R extends ResultType>(sessionId: string, prompt: string, resultType: R, options?: QueryOptions): RunningQuery<InferResult<R>> {
93
- return this.#spawn({ kind: "resume", sessionId }, prompt, resultType, options);
235
+ resumeSession<R extends ResultType>(sessionId: string, resultType: R, options?: QueryOptions): ClaudeProcess<InferResult<R>> {
236
+ return this.#spawn({ kind: "resume", sessionId }, resultType, options);
94
237
  }
95
238
 
96
- forkSession<R extends ResultType>(sessionId: string, prompt: string, resultType: R, options?: QueryOptions): RunningQuery<InferResult<R>> {
97
- return this.#spawn({ kind: "fork", sessionId }, prompt, resultType, options);
239
+ forkSession<R extends ResultType>(sessionId: string, resultType: R, options?: QueryOptions): ClaudeProcess<InferResult<R>> {
240
+ return this.#spawn({ kind: "fork", sessionId }, resultType, options);
98
241
  }
99
242
 
100
- #spawn<R extends ResultType>(mode: SessionMode, prompt: string, resultType: R, options?: QueryOptions): RunningQuery<InferResult<R>> {
243
+ #spawn<R extends ResultType>(mode: SessionMode, resultType: R, options?: QueryOptions): ClaudeProcess<InferResult<R>> {
101
244
  const env = { ...process.env };
102
245
  delete env.CLAUDECODE;
103
246
 
@@ -105,7 +248,13 @@ export class Claude {
105
248
  const systemPrompt = options?.systemPrompt ?? this.#systemPrompt;
106
249
  const sessionId = mode.kind === "resume" ? mode.sessionId : crypto.randomUUID();
107
250
 
108
- const args = ["claude", "-p", "--output-format", "json", "--disallowedTools", "CronList,CronDelete,CronCreate,AskUserQuestion"];
251
+ const args = [
252
+ "claude", "-p",
253
+ "--input-format", "stream-json",
254
+ "--output-format", "stream-json",
255
+ "--verbose",
256
+ "--disallowedTools", "CronList,CronDelete,CronCreate,AskUserQuestion",
257
+ ];
109
258
 
110
259
  if (mode.kind === "resume") {
111
260
  args.push("--resume", sessionId);
@@ -121,60 +270,11 @@ export class Claude {
121
270
 
122
271
  if (model) args.push("--model", model);
123
272
  if (systemPrompt) args.push("--append-system-prompt", systemPrompt);
124
- args.push(prompt);
125
273
 
126
- log.debug({ model, sessionId, promptLen: prompt.length, mode: mode.kind, hasSystemPrompt: !!systemPrompt, prompt }, "Sending to Claude");
274
+ log.debug({ model, sessionId, mode: mode.kind, hasSystemPrompt: !!systemPrompt }, "Spawning Claude process");
127
275
 
128
- const proc = Bun.spawn(args, { cwd: this.#workspace, env, stdout: "pipe", stderr: "pipe" });
129
- const startedAt = new Date();
130
-
131
- const result = (async (): Promise<QueryResult<InferResult<R>>> => {
132
- const exitCode = await proc.exited;
133
- if (exitCode !== 0) {
134
- const stderr = await new Response(proc.stderr).text();
135
- log.error({ exitCode, stderr: stderr.slice(0, 200) }, "Claude process failed");
136
- throw new QueryProcessError(exitCode, stderr);
137
- }
138
-
139
- const stdout = await new Response(proc.stdout).text();
140
- let envelope: Record<string, unknown>;
141
- try {
142
- envelope = JSON.parse(stdout) as Record<string, unknown>;
143
- } catch {
144
- log.warn({ stdout: stdout.slice(0, 200) }, "Failed to parse Claude stdout as JSON");
145
- throw new QueryParseError(stdout);
146
- }
147
-
148
- const sid = typeof envelope.session_id === "string" ? envelope.session_id : sessionId;
149
- const durationMs = typeof envelope.duration_ms === "number" ? envelope.duration_ms : undefined;
150
- const costUsd = typeof envelope.total_cost_usd === "number" ? envelope.total_cost_usd : undefined;
151
- const duration = durationMs ? `${(durationMs / 1000).toFixed(1)}s` : undefined;
152
- const cost = costUsd ? `$${costUsd.toFixed(4)}` : undefined;
153
-
154
- let value: InferResult<R>;
155
- if (resultType.type === "text") {
156
- value = (typeof envelope.result === "string" ? envelope.result : "") as InferResult<R>;
157
- } else {
158
- const raw = envelope.structured_output;
159
- try {
160
- value = resultType.schema.parse(raw) as InferResult<R>;
161
- } catch (err) {
162
- throw new QueryValidationError(raw, err);
163
- }
164
- }
276
+ const proc = Bun.spawn(args, { cwd: this.#workspace, env, stdin: "pipe", stdout: "pipe", stderr: "pipe" });
165
277
 
166
- log.debug({ duration, cost, sessionId: sid }, "Claude response received");
167
- return { value, sessionId: sid, duration, cost };
168
- })();
169
-
170
- return {
171
- sessionId,
172
- startedAt,
173
- result,
174
- kill: async () => {
175
- proc.kill();
176
- await proc.exited;
177
- },
178
- };
278
+ return new ClaudeProcess<InferResult<R>>(proc, sessionId, resultType);
179
279
  }
180
280
  }