macroclaw 0.0.0-dev

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/app.ts ADDED
@@ -0,0 +1,164 @@
1
+ import type { Bot } from "grammy";
2
+ import type { Claude } from "./claude";
3
+ import { CronScheduler } from "./cron";
4
+ import { createLogger } from "./logger";
5
+ import { Orchestrator, type OrchestratorResponse } from "./orchestrator";
6
+ import { isAvailable as isSttAvailable, transcribe } from "./stt";
7
+ import { createBot, downloadFile, sendFile, sendResponse } from "./telegram";
8
+
9
+ const log = createLogger("app");
10
+
11
+ export interface AppConfig {
12
+ botToken: string;
13
+ authorizedChatId: string;
14
+ workspace: string;
15
+ model?: string;
16
+ settingsDir?: string;
17
+ claude?: Claude;
18
+ }
19
+
20
+ export class App {
21
+ #bot: Bot;
22
+ #orchestrator: Orchestrator;
23
+ #config: AppConfig;
24
+
25
+ constructor(config: AppConfig) {
26
+ this.#config = config;
27
+ this.#bot = createBot(config.botToken);
28
+ this.#orchestrator = new Orchestrator({
29
+ model: config.model,
30
+ workspace: config.workspace,
31
+ settingsDir: config.settingsDir,
32
+ claude: config.claude,
33
+ onResponse: (r) => this.#deliverResponse(r),
34
+ });
35
+
36
+ this.#setupHandlers();
37
+ }
38
+
39
+ get bot() {
40
+ return this.#bot;
41
+ }
42
+
43
+ start() {
44
+ log.info("Starting macroclaw...");
45
+ const cron = new CronScheduler(this.#config.workspace, {
46
+ onJob: (name, prompt, model) => this.#orchestrator.handleCron(name, prompt, model),
47
+ });
48
+ cron.start();
49
+ this.#bot.api.setMyCommands([
50
+ { command: "chatid", description: "Show current chat ID" },
51
+ { command: "session", description: "Show current session ID" },
52
+ { command: "bg", description: "List or spawn background agents" },
53
+ ]).catch((err) => log.error({ err }, "Failed to set commands"));
54
+ this.#bot.start({
55
+ onStart: (botInfo) => {
56
+ log.info({ username: botInfo.username, chatId: this.#config.authorizedChatId }, "Bot connected");
57
+ },
58
+ });
59
+ }
60
+
61
+ async #deliverResponse(response: OrchestratorResponse) {
62
+ if (response.files?.length) {
63
+ for (const filePath of response.files) {
64
+ await sendFile(this.#bot, this.#config.authorizedChatId, filePath);
65
+ }
66
+ }
67
+ await sendResponse(this.#bot, this.#config.authorizedChatId, response.message, response.buttons);
68
+ }
69
+
70
+ #setupHandlers() {
71
+ this.#bot.command("chatid", (ctx) => {
72
+ log.debug("Command /chatid");
73
+ ctx.reply(`Chat ID: \`${ctx.chat.id}\``, { parse_mode: "Markdown" });
74
+ });
75
+
76
+ this.#bot.command("session", (ctx) => {
77
+ if (ctx.chat.id.toString() !== this.#config.authorizedChatId) return;
78
+ log.debug("Command /session");
79
+ this.#orchestrator.handleSessionCommand();
80
+ });
81
+
82
+ this.#bot.command("bg", (ctx) => {
83
+ if (ctx.chat.id.toString() !== this.#config.authorizedChatId) return;
84
+ const prompt = ctx.match?.trim();
85
+ if (prompt) {
86
+ log.debug({ prompt }, "Command /bg spawn");
87
+ this.#orchestrator.handleBackgroundCommand(prompt);
88
+ return;
89
+ }
90
+ log.debug("Command /bg list");
91
+ this.#orchestrator.handleBackgroundList();
92
+ });
93
+
94
+ this.#bot.on("message:photo", async (ctx) => {
95
+ if (ctx.chat.id.toString() !== this.#config.authorizedChatId) return;
96
+ const photos = ctx.message.photo;
97
+ const largest = photos[photos.length - 1];
98
+ try {
99
+ const path = await downloadFile(this.#bot, largest.file_id, this.#config.botToken, "photo.jpg");
100
+ this.#orchestrator.handleMessage(ctx.message.caption ?? "", [path]);
101
+ } catch (err) {
102
+ log.error({ err }, "Photo download failed");
103
+ this.#orchestrator.handleMessage(`[File download failed: photo.jpg]\n${ctx.message.caption ?? ""}`);
104
+ }
105
+ });
106
+
107
+ this.#bot.on("message:document", async (ctx) => {
108
+ if (ctx.chat.id.toString() !== this.#config.authorizedChatId) return;
109
+ const doc = ctx.message.document;
110
+ const name = doc.file_name ?? "file";
111
+ try {
112
+ const path = await downloadFile(this.#bot, doc.file_id, this.#config.botToken, name);
113
+ this.#orchestrator.handleMessage(ctx.message.caption ?? "", [path]);
114
+ } catch (err) {
115
+ log.error({ err }, "Document download failed");
116
+ this.#orchestrator.handleMessage(`[File download failed: ${name}]\n${ctx.message.caption ?? ""}`);
117
+ }
118
+ });
119
+
120
+ this.#bot.on("message:voice", async (ctx) => {
121
+ if (ctx.chat.id.toString() !== this.#config.authorizedChatId) return;
122
+ if (!isSttAvailable()) {
123
+ await sendResponse(this.#bot, this.#config.authorizedChatId, "[Voice messages not available — set OPENAI_API_KEY to enable]");
124
+ return;
125
+ }
126
+ try {
127
+ const path = await downloadFile(this.#bot, ctx.message.voice.file_id, this.#config.botToken, "voice.ogg");
128
+ const text = await transcribe(path);
129
+ if (!text.trim()) {
130
+ await sendResponse(this.#bot, this.#config.authorizedChatId, "[Could not understand audio]");
131
+ return;
132
+ }
133
+ await sendResponse(this.#bot, this.#config.authorizedChatId, `[Received audio]: ${text}`);
134
+ this.#orchestrator.handleMessage(text);
135
+ } catch (err) {
136
+ log.error({ err }, "Voice transcription failed");
137
+ await sendResponse(this.#bot, this.#config.authorizedChatId, "[Failed to transcribe audio]");
138
+ }
139
+ });
140
+
141
+ this.#bot.on("callback_query:data", async (ctx) => {
142
+ await ctx.answerCallbackQuery();
143
+ const label = ctx.callbackQuery.data;
144
+ if (label === "_noop") return;
145
+ await ctx.editMessageReplyMarkup({ reply_markup: { inline_keyboard: [[{ text: `✓ ${label}`, callback_data: "_noop" }]] } });
146
+ if (ctx.chat?.id.toString() !== this.#config.authorizedChatId) return;
147
+ log.debug({ label }, "Button clicked");
148
+ this.#orchestrator.handleButton(label);
149
+ });
150
+
151
+ this.#bot.on("message:text", (ctx) => {
152
+ if (ctx.chat.id.toString() !== this.#config.authorizedChatId) {
153
+ log.debug({ chatId: ctx.chat.id }, "Unauthorized message");
154
+ return;
155
+ }
156
+
157
+ this.#orchestrator.handleMessage(ctx.message.text);
158
+ });
159
+
160
+ this.#bot.catch((err) => {
161
+ log.error({ err }, "Bot error");
162
+ });
163
+ }
164
+ }
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Integration test for Claude CLI structured output.
3
+ * Run manually: bun test src/claude.integration.test.ts --timeout 120000
4
+ */
5
+ import { describe, expect, it } from "bun:test";
6
+ import { randomUUID } from "node:crypto";
7
+ import { Claude, type ClaudeResult, isDeferred } from "./claude";
8
+
9
+ const WORKSPACE = "/tmp/macroclaw-integration-test";
10
+ const SIMPLE_SCHEMA = JSON.stringify({
11
+ type: "object",
12
+ properties: {
13
+ action: { type: "string", enum: ["send", "silent"] },
14
+ actionReason: { type: "string" },
15
+ message: { type: "string" },
16
+ },
17
+ required: ["action", "actionReason"],
18
+ additionalProperties: false,
19
+ });
20
+
21
+ const FULL_SCHEMA = JSON.stringify({
22
+ type: "object",
23
+ properties: {
24
+ action: { type: "string", enum: ["send", "silent"], description: "'send' to reply to the user, 'silent' to do nothing" },
25
+ actionReason: { type: "string", description: "Why the agent chose this action (logged, not sent)" },
26
+ message: { type: "string", description: "The message to send to Telegram (required when action is 'send')" },
27
+ files: { type: "array", items: { type: "string" }, description: "Absolute paths to files to send to Telegram" },
28
+ backgroundAgents: {
29
+ type: "array",
30
+ items: {
31
+ type: "object",
32
+ properties: {
33
+ name: { type: "string" },
34
+ prompt: { type: "string" },
35
+ model: { type: "string", enum: ["haiku", "sonnet", "opus"] },
36
+ },
37
+ required: ["name", "prompt"],
38
+ additionalProperties: false,
39
+ },
40
+ },
41
+ },
42
+ required: ["action", "actionReason"],
43
+ additionalProperties: false,
44
+ });
45
+
46
+ async function runSync(claude: Claude, ...args: Parameters<Claude["run"]>): Promise<ClaudeResult> {
47
+ const result = await claude.run(...args);
48
+ if (isDeferred(result)) throw new Error("Expected sync result, got deferred");
49
+ return result;
50
+ }
51
+
52
+ describe("claude CLI structured output", () => {
53
+ it("simple schema without system prompt", async () => {
54
+ const claude = new Claude({ workspace: WORKSPACE, jsonSchema: SIMPLE_SCHEMA });
55
+ const result = await runSync(claude, {
56
+ prompt: "Say hello",
57
+ sessionFlag: "--session-id",
58
+ sessionId: randomUUID(),
59
+ model: "haiku",
60
+ });
61
+
62
+ console.log("Simple (no sysprompt):", JSON.stringify(result, null, 2));
63
+ expect(result.structuredOutput).not.toBeNull();
64
+ }, 60_000);
65
+
66
+ it("simple schema with system prompt", async () => {
67
+ const claude = new Claude({ workspace: WORKSPACE, jsonSchema: SIMPLE_SCHEMA });
68
+ const result = await runSync(claude, {
69
+ prompt: "Say hello",
70
+ sessionFlag: "--session-id",
71
+ sessionId: randomUUID(),
72
+ model: "haiku",
73
+ systemPrompt: "You are a helpful assistant. This is a direct message from the user.",
74
+ });
75
+
76
+ console.log("Simple (with sysprompt):", JSON.stringify(result, null, 2));
77
+ expect(result.structuredOutput).not.toBeNull();
78
+ }, 60_000);
79
+
80
+ it("full schema with system prompt", async () => {
81
+ const claude = new Claude({ workspace: WORKSPACE, jsonSchema: FULL_SCHEMA });
82
+ const result = await runSync(claude, {
83
+ prompt: "Say hello",
84
+ sessionFlag: "--session-id",
85
+ sessionId: randomUUID(),
86
+ model: "haiku",
87
+ systemPrompt: "You are a helpful assistant. This is a direct message from the user.",
88
+ });
89
+
90
+ console.log("Full (with sysprompt):", JSON.stringify(result, null, 2));
91
+ expect(result.structuredOutput).not.toBeNull();
92
+ }, 60_000);
93
+
94
+ it("full schema with real system prompt and workspace", async () => {
95
+ const workspace = process.env.MACROCLAW_WORKSPACE ?? WORKSPACE;
96
+ const claude = new Claude({ workspace, jsonSchema: FULL_SCHEMA });
97
+ const result = await runSync(claude, {
98
+ prompt: "Say hello",
99
+ sessionFlag: "--session-id",
100
+ sessionId: randomUUID(),
101
+ model: "sonnet",
102
+ systemPrompt: `You are an AI assistant running inside macroclaw. This is a direct message from the user.`,
103
+ });
104
+
105
+ console.log("Full (real workspace):", JSON.stringify(result, null, 2));
106
+ expect(result.structuredOutput).not.toBeNull();
107
+ }, 120_000);
108
+ });
@@ -0,0 +1,247 @@
1
+ import { afterEach, describe, expect, it, mock } from "bun:test";
2
+ import { Claude, type ClaudeDeferredResult, ClaudeParseError, ClaudeProcessError, type ClaudeResult, type ClaudeRunOptions, isDeferred } from "./claude";
3
+
4
+ function jsonResult(structuredOutput: unknown, sessionId = "test-session-id"): string {
5
+ return JSON.stringify({
6
+ type: "result",
7
+ subtype: "success",
8
+ duration_ms: 1234,
9
+ total_cost_usd: 0.05,
10
+ result: "",
11
+ session_id: sessionId,
12
+ structured_output: structuredOutput,
13
+ });
14
+ }
15
+
16
+ const originalSpawn = Bun.spawn;
17
+
18
+ afterEach(() => {
19
+ Bun.spawn = originalSpawn;
20
+ });
21
+
22
+ function mockSpawn(opts: {
23
+ stdout?: string;
24
+ stderr?: string;
25
+ exitCode?: number;
26
+ }) {
27
+ const proc = {
28
+ stdout: new Response(opts.stdout ?? "").body,
29
+ stderr: new Response(opts.stderr ?? "").body,
30
+ exited: Promise.resolve(opts.exitCode ?? 0),
31
+ kill: mock(() => {}),
32
+ };
33
+
34
+ Bun.spawn = mock((() => proc) as any);
35
+ return proc;
36
+ }
37
+
38
+ const TEST_WORKSPACE = "/tmp/macroclaw-test-workspace";
39
+ const DUMMY_SCHEMA = '{"type":"object"}';
40
+
41
+ function makeClaude() {
42
+ return new Claude({ workspace: TEST_WORKSPACE, jsonSchema: DUMMY_SCHEMA });
43
+ }
44
+
45
+ function opts(overrides?: Partial<ClaudeRunOptions>): ClaudeRunOptions {
46
+ return {
47
+ prompt: "test message",
48
+ sessionFlag: "--session-id",
49
+ sessionId: "sid-1",
50
+ ...overrides,
51
+ };
52
+ }
53
+
54
+ async function runSync(claude: Claude, options: ClaudeRunOptions): Promise<ClaudeResult> {
55
+ const result = await claude.run(options);
56
+ if (isDeferred(result)) throw new Error("Expected sync result, got deferred");
57
+ return result;
58
+ }
59
+
60
+ describe("Claude", () => {
61
+ it("passes --session-id flag when given", async () => {
62
+ mockSpawn({ stdout: jsonResult({ action: "send", message: "Hello" }), exitCode: 0 });
63
+ const claude = makeClaude();
64
+ const result = await runSync(claude, opts());
65
+ expect(result.structuredOutput).toEqual({ action: "send", message: "Hello" });
66
+ expect(Bun.spawn).toHaveBeenCalledWith(
67
+ expect.arrayContaining(["claude", "-p", "--session-id", "sid-1", "--output-format", "json", "--json-schema"]),
68
+ expect.objectContaining({ cwd: TEST_WORKSPACE, stdout: "pipe", stderr: "pipe" }),
69
+ );
70
+ });
71
+
72
+ it("passes --resume flag when given", async () => {
73
+ mockSpawn({ stdout: jsonResult({ action: "send" }), exitCode: 0 });
74
+ const claude = makeClaude();
75
+ await claude.run(opts({ sessionFlag: "--resume", sessionId: "sid-2" }));
76
+ expect(Bun.spawn).toHaveBeenCalledWith(
77
+ expect.arrayContaining(["claude", "-p", "--resume", "sid-2"]),
78
+ expect.objectContaining({ cwd: TEST_WORKSPACE }),
79
+ );
80
+ });
81
+
82
+ it("passes model flag when provided", async () => {
83
+ mockSpawn({ stdout: jsonResult({ action: "send" }), exitCode: 0 });
84
+ const claude = makeClaude();
85
+ await claude.run(opts({ sessionId: "sid-3", model: "haiku", prompt: "msg" }));
86
+ expect(Bun.spawn).toHaveBeenCalledWith(
87
+ expect.arrayContaining(["--model", "haiku", "msg"]),
88
+ expect.objectContaining({ cwd: TEST_WORKSPACE }),
89
+ );
90
+ });
91
+
92
+ it("passes --append-system-prompt when provided", async () => {
93
+ mockSpawn({ stdout: jsonResult({ action: "send" }), exitCode: 0 });
94
+ const claude = makeClaude();
95
+ await claude.run(opts({ sessionId: "sid-4", systemPrompt: "You are a background agent.", prompt: "msg" }));
96
+ expect(Bun.spawn).toHaveBeenCalledWith(
97
+ expect.arrayContaining(["--append-system-prompt", "You are a background agent.", "msg"]),
98
+ expect.objectContaining({ cwd: TEST_WORKSPACE }),
99
+ );
100
+ });
101
+
102
+ it("passes --fork-session when forkSession is true", async () => {
103
+ mockSpawn({ stdout: jsonResult({ action: "send" }), exitCode: 0 });
104
+ const claude = makeClaude();
105
+ await claude.run(opts({ sessionFlag: "--resume", sessionId: "sid-fork", forkSession: true, prompt: "bg task" }));
106
+ expect(Bun.spawn).toHaveBeenCalledWith(
107
+ expect.arrayContaining(["--resume", "sid-fork", "--fork-session"]),
108
+ expect.objectContaining({ cwd: TEST_WORKSPACE }),
109
+ );
110
+ });
111
+
112
+ it("omits --fork-session when not specified", async () => {
113
+ mockSpawn({ stdout: jsonResult({ action: "send" }), exitCode: 0 });
114
+ const claude = makeClaude();
115
+ await claude.run(opts({ sessionId: "sid-nofork" }));
116
+ const args = (Bun.spawn as any).mock.calls[0][0] as string[];
117
+ expect(args).not.toContain("--fork-session");
118
+ });
119
+
120
+ it("omits --append-system-prompt when undefined", async () => {
121
+ mockSpawn({ stdout: jsonResult({ action: "send" }), exitCode: 0 });
122
+ const claude = makeClaude();
123
+ await claude.run(opts({ sessionId: "sid-5" }));
124
+ const args = (Bun.spawn as any).mock.calls[0][0] as string[];
125
+ expect(args).not.toContain("--append-system-prompt");
126
+ });
127
+
128
+ it("throws ClaudeProcessError on non-zero exit", async () => {
129
+ mockSpawn({ stderr: "something went wrong", exitCode: 1 });
130
+ const claude = makeClaude();
131
+ try {
132
+ await claude.run(opts({ sessionId: "sid-6" }));
133
+ expect.unreachable("should have thrown");
134
+ } catch (err) {
135
+ expect(err).toBeInstanceOf(ClaudeProcessError);
136
+ expect((err as ClaudeProcessError).exitCode).toBe(1);
137
+ expect((err as ClaudeProcessError).stderr).toBe("something went wrong");
138
+ }
139
+ });
140
+
141
+ it("returns deferred result when process exceeds timeout", async () => {
142
+ let resolveExited!: (code: number) => void;
143
+ const stdoutData = jsonResult({ action: "send", message: "done" });
144
+ const proc = {
145
+ stdout: new Response(stdoutData).body,
146
+ stderr: new Response("").body,
147
+ exited: new Promise<number>((resolve) => {
148
+ resolveExited = resolve;
149
+ }),
150
+ kill: mock(() => {}),
151
+ };
152
+
153
+ const origSetTimeout = globalThis.setTimeout;
154
+ globalThis.setTimeout = ((fn: Function) => {
155
+ fn();
156
+ return 0 as any;
157
+ }) as any;
158
+
159
+ Bun.spawn = mock((() => proc) as any);
160
+ const claude = makeClaude();
161
+ try {
162
+ const result = await claude.run(opts({ sessionId: "sid-7", timeoutMs: 60_000 }));
163
+ expect("deferred" in result && result.deferred).toBe(true);
164
+ const deferred = result as ClaudeDeferredResult;
165
+ expect(deferred.sessionId).toBe("sid-7");
166
+
167
+ // Process hasn't been killed
168
+ expect(proc.kill).not.toHaveBeenCalled();
169
+
170
+ // Resolve the process — completion should resolve
171
+ resolveExited(0);
172
+ const completed = await deferred.completion;
173
+ expect(completed.structuredOutput).toEqual({ action: "send", message: "done" });
174
+ } finally {
175
+ globalThis.setTimeout = origSetTimeout;
176
+ }
177
+ });
178
+
179
+ it("returns structured_output from successful response", async () => {
180
+ mockSpawn({ stdout: jsonResult({ action: "silent", actionReason: "no new results" }), exitCode: 0 });
181
+ const claude = makeClaude();
182
+ const result = await runSync(claude, opts({ sessionFlag: "--resume", sessionId: "sid-8" }));
183
+ expect(result.structuredOutput).toEqual({ action: "silent", actionReason: "no new results" });
184
+ expect(result.sessionId).toBe("test-session-id");
185
+ });
186
+
187
+ it("returns null structuredOutput and result text when structured_output is missing", async () => {
188
+ const envelope = JSON.stringify({ type: "result", result: "plain text", duration_ms: 100, total_cost_usd: 0.01, session_id: "sid-abc" });
189
+ mockSpawn({ stdout: envelope, exitCode: 0 });
190
+ const claude = makeClaude();
191
+ const result = await runSync(claude, opts({ sessionFlag: "--resume", sessionId: "sid-9" }));
192
+ expect(result.structuredOutput).toBeNull();
193
+ expect(result.result).toBe("plain text");
194
+ expect(result.sessionId).toBe("sid-abc");
195
+ });
196
+
197
+ it("returns empty sessionId when session_id is missing from envelope", async () => {
198
+ const envelope = JSON.stringify({ type: "result", result: "text", duration_ms: 100, total_cost_usd: 0.01 });
199
+ mockSpawn({ stdout: envelope, exitCode: 0 });
200
+ const claude = makeClaude();
201
+ const result = await runSync(claude, opts({ sessionFlag: "--resume", sessionId: "sid-9c" }));
202
+ expect(result.sessionId).toBe("");
203
+ });
204
+
205
+ it("returns result from envelope", async () => {
206
+ mockSpawn({ stdout: jsonResult({ action: "send" }), exitCode: 0 });
207
+ const claude = makeClaude();
208
+ const result = await runSync(claude, opts({ sessionFlag: "--resume", sessionId: "sid-9b" }));
209
+ expect(result.result).toBe("");
210
+ });
211
+
212
+ it("throws ClaudeParseError when stdout is not valid JSON", async () => {
213
+ mockSpawn({ stdout: "not json at all", exitCode: 0 });
214
+ const claude = makeClaude();
215
+ try {
216
+ await runSync(claude, opts({ sessionFlag: "--resume", sessionId: "sid-10" }));
217
+ expect.unreachable("should have thrown");
218
+ } catch (err) {
219
+ expect(err).toBeInstanceOf(ClaudeParseError);
220
+ expect((err as ClaudeParseError).raw).toBe("not json at all");
221
+ }
222
+ });
223
+
224
+ it("returns duration and cost from envelope", async () => {
225
+ mockSpawn({ stdout: jsonResult({ action: "send" }), exitCode: 0 });
226
+ const claude = makeClaude();
227
+ const result = await runSync(claude, opts({ sessionFlag: "--resume", sessionId: "sid-11" }));
228
+ expect(result.duration).toBe("1.2s");
229
+ expect(result.cost).toBe("$0.0500");
230
+ });
231
+
232
+ it("does not set timeout when timeoutMs is not provided", async () => {
233
+ mockSpawn({ stdout: jsonResult({ action: "send" }), exitCode: 0 });
234
+ const claude = makeClaude();
235
+ const result = await runSync(claude, opts({ sessionFlag: "--resume", sessionId: "sid-12" }));
236
+ expect(result.structuredOutput).toEqual({ action: "send" });
237
+ });
238
+
239
+ it("passes jsonSchema to CLI args", async () => {
240
+ const schema = '{"type":"object","properties":{}}';
241
+ mockSpawn({ stdout: jsonResult({ action: "send" }), exitCode: 0 });
242
+ const claude = new Claude({ workspace: TEST_WORKSPACE, jsonSchema: schema });
243
+ await claude.run(opts({ sessionId: "sid-13" }));
244
+ const args = (Bun.spawn as any).mock.calls[0][0] as string[];
245
+ expect(args).toContain(schema);
246
+ });
247
+ });
package/src/claude.ts ADDED
@@ -0,0 +1,136 @@
1
+ import { createLogger } from "./logger";
2
+
3
+ const log = createLogger("claude");
4
+
5
+ export interface ClaudeRunOptions {
6
+ prompt: string;
7
+ sessionFlag: "--resume" | "--session-id";
8
+ sessionId: string;
9
+ forkSession?: boolean;
10
+ model?: string;
11
+ systemPrompt?: string;
12
+ timeoutMs?: number;
13
+ }
14
+
15
+ export interface ClaudeResult {
16
+ structuredOutput: unknown;
17
+ sessionId: string;
18
+ result?: string;
19
+ duration?: string;
20
+ cost?: string;
21
+ }
22
+
23
+ export interface ClaudeDeferredResult {
24
+ deferred: true;
25
+ sessionId: string;
26
+ completion: Promise<ClaudeResult>;
27
+ }
28
+
29
+ export function isDeferred<T>(result: T | ClaudeDeferredResult): result is ClaudeDeferredResult {
30
+ return result != null && typeof result === "object" && "deferred" in result && (result as ClaudeDeferredResult).deferred === true;
31
+ }
32
+
33
+ export class ClaudeProcessError extends Error {
34
+ constructor(
35
+ public exitCode: number,
36
+ public stderr: string,
37
+ ) {
38
+ super(`Claude exited with code ${exitCode}:\n${stderr}`);
39
+ }
40
+ }
41
+
42
+ export class ClaudeParseError extends Error {
43
+ constructor(public raw: string) {
44
+ super(`Failed to parse Claude output as JSON`);
45
+ }
46
+ }
47
+
48
+ function parseEnvelope(stdout: string): ClaudeResult {
49
+ try {
50
+ const envelope = JSON.parse(stdout);
51
+ const duration = envelope.duration_ms ? `${(envelope.duration_ms / 1000).toFixed(1)}s` : undefined;
52
+ const cost = envelope.total_cost_usd ? `$${envelope.total_cost_usd.toFixed(4)}` : undefined;
53
+ const structuredOutput = envelope.structured_output ?? null;
54
+ if (!structuredOutput) {
55
+ log.debug({ envelope }, "No structured_output in envelope");
56
+ }
57
+ const sid = typeof envelope.session_id === "string" ? envelope.session_id : "";
58
+ const result = typeof envelope.result === "string" ? envelope.result : undefined;
59
+ log.debug({ duration, cost, sessionId: sid }, "Claude response received");
60
+ return { structuredOutput, sessionId: sid, result, duration, cost };
61
+ } catch {
62
+ log.warn({ stdout: stdout.slice(0, 200) }, "Failed to parse Claude stdout as JSON");
63
+ throw new ClaudeParseError(stdout);
64
+ }
65
+ }
66
+
67
+ async function awaitProcess(proc: { exited: Promise<number>; stdout: ReadableStream<Uint8Array> | null; stderr: ReadableStream<Uint8Array> | null }): Promise<ClaudeResult> {
68
+ const exitCode = await proc.exited;
69
+ if (exitCode !== 0) {
70
+ const stderr = await new Response(proc.stderr).text();
71
+ log.error({ exitCode, stderr: stderr.slice(0, 200) }, "Claude process failed");
72
+ throw new ClaudeProcessError(exitCode, stderr);
73
+ }
74
+ const stdout = await new Response(proc.stdout).text();
75
+ return parseEnvelope(stdout);
76
+ }
77
+
78
+ export class Claude {
79
+ #workspace: string;
80
+ #jsonSchema: string;
81
+
82
+ constructor(config: { workspace: string; jsonSchema: string }) {
83
+ this.#workspace = config.workspace;
84
+ this.#jsonSchema = config.jsonSchema;
85
+ }
86
+
87
+ async run(options: ClaudeRunOptions): Promise<ClaudeResult | ClaudeDeferredResult> {
88
+ // Strip CLAUDECODE env var so nested claude sessions are allowed
89
+ const env = { ...process.env };
90
+ delete env.CLAUDECODE;
91
+
92
+ const args = ["claude", "-p", options.sessionFlag, options.sessionId, "--output-format", "json", "--json-schema", this.#jsonSchema];
93
+ if (options.forkSession) args.push("--fork-session");
94
+ if (options.model) args.push("--model", options.model);
95
+ if (options.systemPrompt) args.push("--append-system-prompt", options.systemPrompt);
96
+ args.push(options.prompt);
97
+
98
+ log.debug(
99
+ {
100
+ model: options.model,
101
+ sessionFlag: options.sessionFlag,
102
+ sessionId: options.sessionId,
103
+ promptLen: options.prompt.length,
104
+ hasSystemPrompt: !!options.systemPrompt,
105
+ },
106
+ "Sending to Claude",
107
+ );
108
+
109
+ const proc = Bun.spawn(args, {
110
+ cwd: this.#workspace,
111
+ env,
112
+ stdout: "pipe",
113
+ stderr: "pipe",
114
+ });
115
+
116
+ const completion = awaitProcess(proc);
117
+
118
+ if (!options.timeoutMs) {
119
+ return completion;
120
+ }
121
+
122
+ const result = await Promise.race([
123
+ completion.then((r) => ({ kind: "done" as const, value: r })),
124
+ new Promise<{ kind: "timeout" }>((resolve) => setTimeout(() => resolve({ kind: "timeout" }), options.timeoutMs)),
125
+ ]);
126
+
127
+ if (result.kind === "done") return result.value;
128
+
129
+ log.info({ timeoutMs: options.timeoutMs, sessionId: options.sessionId }, "Claude process timed out, deferring to background");
130
+ return {
131
+ deferred: true,
132
+ sessionId: options.sessionId,
133
+ completion,
134
+ };
135
+ }
136
+ }