macroclaw 0.32.0 → 0.34.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/package.json +1 -1
- package/src/app.test.ts +159 -103
- package/src/app.ts +13 -0
- package/src/claude.integration-test.ts +65 -27
- package/src/claude.test.ts +369 -189
- package/src/claude.ts +171 -71
- package/src/orchestrator.test.ts +301 -249
- package/src/orchestrator.ts +157 -96
- package/workspace-template/.claude/skills/self-update/SKILL.md +1 -1
- package/workspace-template/.claude/skills/self-update/scripts/update.sh +3 -4
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>(
|
|
89
|
-
return this.#spawn({ kind: "new" },
|
|
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,
|
|
93
|
-
return this.#spawn({ kind: "resume", sessionId },
|
|
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,
|
|
97
|
-
return this.#spawn({ kind: "fork", sessionId },
|
|
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,
|
|
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 = [
|
|
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,
|
|
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
|
-
|
|
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
|
}
|