macroclaw 0.31.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.
@@ -1,15 +1,26 @@
1
1
  import { z } from "zod/v4";
2
2
  import {
3
3
  Claude,
4
+ type ClaudeProcess,
4
5
  QueryParseError,
5
6
  QueryProcessError,
7
+ type QueryResult,
6
8
  QueryValidationError,
7
- type RunningQuery
8
9
  } from "./claude";
9
10
  import { writeHistoryPrompt, writeHistoryResult } from "./history";
10
11
  import { createLogger } from "./logger";
11
12
  import { generateName } from "./naming";
12
- import { buildEvent, type EventInput, SYSTEM_PROMPT } from "./prompts";
13
+ import {
14
+ backgroundAgentProgressEvent,
15
+ backgroundAgentResultEvent,
16
+ backgroundAgentStartEvent,
17
+ buttonClickEvent,
18
+ healthCheckEvent,
19
+ peekEvent,
20
+ SYSTEM_PROMPT,
21
+ scheduleTriggerEvent,
22
+ userMessageEvent,
23
+ } from "./prompts";
13
24
  import { Queue } from "./queue";
14
25
  import { loadSessions, saveSessions } from "./sessions";
15
26
 
@@ -82,7 +93,8 @@ interface SessionInfo {
82
93
  name: string;
83
94
  prompt: string;
84
95
  model?: string;
85
- query: RunningQuery<AgentOutput>;
96
+ process: ClaudeProcess<AgentOutput>;
97
+ pendingResult: Promise<QueryResult<AgentOutput>>;
86
98
  lastMessageAt: Date;
87
99
  healthCheckTimer?: Timer;
88
100
  }
@@ -109,6 +121,7 @@ export class Orchestrator {
109
121
  #healthCheckTimeout: number;
110
122
 
111
123
  #mainSessionId: string | undefined;
124
+ #mainProcess: ClaudeProcess<AgentOutput> | null = null;
112
125
  #runningSessions = new Map<string, SessionInfo>();
113
126
  #queue: Queue<OrchestratorRequest>;
114
127
 
@@ -136,13 +149,11 @@ export class Orchestrator {
136
149
 
137
150
  handleCron(name: string, prompt: string, model?: string, missed?: { missedBy: string; scheduledAt: string }): void {
138
151
  const cronName = `cron-${name}`;
139
- const formatted = buildEvent({
140
- name: cronName,
141
- type: "schedule-trigger",
142
- session: "background",
143
- schedule: { name, missedBy: missed?.missedBy, scheduledAt: missed?.scheduledAt },
144
- text: prompt,
145
- });
152
+ const formatted = scheduleTriggerEvent(
153
+ cronName,
154
+ { name, missedBy: missed?.missedBy, scheduledAt: missed?.scheduledAt },
155
+ prompt,
156
+ );
146
157
  this.#spawnBackgroundRaw(cronName, prompt, formatted, model ?? this.#config.model);
147
158
  }
148
159
 
@@ -159,14 +170,14 @@ export class Orchestrator {
159
170
  return;
160
171
  }
161
172
  const lines = sessions.map(([sid, s]) => {
162
- const elapsed = Math.round((Date.now() - s.query.startedAt.getTime()) / 1000);
173
+ const elapsed = Math.round((Date.now() - s.process.startedAt.getTime()) / 1000);
163
174
  const isMain = sid === this.#mainSessionId;
164
175
  return isMain
165
176
  ? `▶ ${escapeHtml(s.name)} (${elapsed}s) [main]`
166
177
  : `- ${escapeHtml(s.name)} (${elapsed}s)`;
167
178
  });
168
179
  const buttons: ButtonSpec[] = sessions.map(([sid, s]) => {
169
- const elapsed = Math.round((Date.now() - s.query.startedAt.getTime()) / 1000);
180
+ const elapsed = Math.round((Date.now() - s.process.startedAt.getTime()) / 1000);
170
181
  const text = `${s.name} (${elapsed}s)`.slice(0, 27);
171
182
  return { text, data: `detail:${sid}` };
172
183
  });
@@ -181,7 +192,7 @@ export class Orchestrator {
181
192
  return;
182
193
  }
183
194
 
184
- const elapsed = Math.round((Date.now() - session.query.startedAt.getTime()) / 1000);
195
+ const elapsed = Math.round((Date.now() - session.process.startedAt.getTime()) / 1000);
185
196
  const truncatedPrompt = session.prompt.length > 300 ? `${session.prompt.slice(0, 300)}…` : session.prompt;
186
197
  const isMain = sessionId === this.#mainSessionId;
187
198
  const lines = [
@@ -209,20 +220,18 @@ export class Orchestrator {
209
220
  this.#callOnResponse({ message: `Peeking at <b>${escapeHtml(session.name)}</b>...` });
210
221
 
211
222
  try {
212
- const prompt = buildEvent({
213
- name: `peek-${session.name}`,
214
- type: "peek",
215
- session: "background",
216
- targetEvent: session.name,
217
- instructions: `Only consider progress since the "${session.name}" event. Brief status update: done, in progress, remaining. 2-3 sentences max, plain text.`,
218
- });
219
- const query = this.#claude.forkSession(
223
+ const prompt = peekEvent(
224
+ `peek-${session.name}`,
225
+ session.name,
226
+ `Only consider progress since the "${session.name}" event. Brief status update: done, in progress, remaining. 2-3 sentences max, plain text.`,
227
+ );
228
+ const peekProcess = this.#claude.forkSession(
220
229
  sessionId,
221
- prompt,
222
230
  textResultType,
223
231
  { model: "haiku" },
224
232
  );
225
- const { value } = await query.result;
233
+ const { value } = await peekProcess.send(prompt);
234
+ peekProcess.kill().catch(() => {});
226
235
  this.#callOnResponse({ message: `<b>[${escapeHtml(session.name)}]</b> ${value || "[No output]"}` });
227
236
  } catch (err) {
228
237
  this.#callOnResponse({ message: `Couldn't peek at ${escapeHtml(session.name)}: ${err}` });
@@ -239,7 +248,7 @@ export class Orchestrator {
239
248
  this.#clearSession(sessionId);
240
249
 
241
250
  try {
242
- await session.query.kill();
251
+ await session.process.kill();
243
252
  } catch (err) {
244
253
  log.error({ err, name: session.name }, "Kill failed");
245
254
  }
@@ -247,102 +256,127 @@ export class Orchestrator {
247
256
  this.#callOnResponse({ message: `Killed <b>${escapeHtml(session.name)}</b>.` });
248
257
  }
249
258
 
259
+ async handleClear(): Promise<void> {
260
+ const sid = this.#mainProcess?.sessionId;
261
+ if (sid) {
262
+ this.#clearSession(sid);
263
+ }
264
+ if (this.#mainProcess) {
265
+ try {
266
+ await this.#mainProcess.kill();
267
+ } catch (err) {
268
+ log.error({ err }, "Failed to kill main process during clear");
269
+ }
270
+ this.#mainProcess = null;
271
+ }
272
+ this.#mainSessionId = undefined;
273
+ saveSessions({}, this.#config.settingsDir);
274
+ log.info("Session cleared");
275
+ this.#callOnResponse({ message: "Session cleared." });
276
+ }
277
+
278
+ /** Tear down all sessions and the main process. Used in tests. */
279
+ async dispose(): Promise<void> {
280
+ for (const sid of [...this.#runningSessions.keys()]) {
281
+ this.#clearSession(sid);
282
+ }
283
+ if (this.#mainProcess) {
284
+ await this.#mainProcess.kill().catch(() => {});
285
+ this.#mainProcess = null;
286
+ }
287
+ }
288
+
289
+ // --- Main process lifecycle ---
290
+
291
+ #ensureMainProcess(): ClaudeProcess<AgentOutput> {
292
+ if (this.#mainProcess && this.#mainProcess.state !== "dead") {
293
+ return this.#mainProcess;
294
+ }
295
+
296
+ const opts = { model: this.#config.model };
297
+ this.#mainProcess = this.#mainSessionId
298
+ ? this.#claude.resumeSession(this.#mainSessionId, responseResultType, opts)
299
+ : this.#claude.newSession(responseResultType, opts);
300
+
301
+ if (this.#mainProcess.sessionId !== this.#mainSessionId) {
302
+ this.#mainSessionId = this.#mainProcess.sessionId;
303
+ saveSessions({ mainSessionId: this.#mainSessionId }, this.#config.settingsDir);
304
+ }
305
+
306
+ log.info({ sessionId: this.#mainSessionId }, "Main process created");
307
+ return this.#mainProcess;
308
+ }
250
309
 
251
310
  // --- Internal queue handler ---
252
311
 
253
312
  async #handleRequest(request: OrchestratorRequest): Promise<void> {
254
313
  log.debug({ type: request.type }, "Incoming request");
255
314
 
256
- const mainInfo = this.#mainSessionId ? this.#runningSessions.get(this.#mainSessionId) : undefined;
257
315
  let movedToBackground: string | undefined;
258
-
259
- if (mainInfo) {
260
- const elapsed = Date.now() - mainInfo.lastMessageAt.getTime();
261
- if (elapsed >= this.#waitThreshold) {
262
- // Main has been running too long — move to background immediately
263
- log.info({ name: mainInfo.name, sessionId: mainInfo.query.sessionId }, "Moving main session to background (exceeded threshold)");
264
- movedToBackground = mainInfo.prompt;
265
- } else {
266
- // Main started recently — wait for it to finish or threshold
267
- const remaining = this.#waitThreshold - elapsed;
268
- const finished = await Promise.race([
269
- mainInfo.query.result.then(() => true as const, () => true as const),
270
- new Promise<false>((r) => setTimeout(() => r(false), remaining)),
271
- ]);
272
-
273
- if (!finished) {
274
- log.info({ name: mainInfo.name, sessionId: mainInfo.query.sessionId }, "Moving main session to background (wait timed out)");
316
+ let backgroundedName: string | undefined;
317
+ const currentMain = this.#mainProcess;
318
+
319
+ if (currentMain?.state === "busy") {
320
+ const mainInfo = this.#runningSessions.get(currentMain.sessionId);
321
+ if (mainInfo) {
322
+ const elapsed = Date.now() - mainInfo.lastMessageAt.getTime();
323
+ if (elapsed >= this.#waitThreshold) {
275
324
  movedToBackground = mainInfo.prompt;
325
+ backgroundedName = mainInfo.name;
326
+ } else {
327
+ const remaining = this.#waitThreshold - elapsed;
328
+ const finished = await Promise.race([
329
+ mainInfo.pendingResult.then(() => true as const, () => true as const),
330
+ new Promise<false>((r) => setTimeout(() => r(false), remaining)),
331
+ ]);
332
+
333
+ if (!finished) {
334
+ movedToBackground = mainInfo.prompt;
335
+ backgroundedName = mainInfo.name;
336
+ }
276
337
  }
277
- // If finished: completion handler already delivered the result and removed from map.
278
338
  }
279
339
  }
280
340
 
341
+ if (movedToBackground && currentMain) {
342
+ log.info({ name: backgroundedName, sessionId: currentMain.sessionId }, "Moving main session to background");
343
+ this.#mainProcess = this.#claude.forkSession(
344
+ this.#mainSessionId ?? currentMain.sessionId,
345
+ responseResultType,
346
+ { model: this.#config.model },
347
+ );
348
+ this.#mainSessionId = this.#mainProcess.sessionId;
349
+ saveSessions({ mainSessionId: this.#mainSessionId }, this.#config.settingsDir);
350
+ }
351
+
281
352
  await writeHistoryPrompt(request);
282
353
 
283
354
  const label = Orchestrator.#requestLabel(request);
284
355
  const name = generateName(label);
285
- const backgroundedName = movedToBackground ? mainInfo?.name : undefined;
286
356
  const formatted = this.#formatPrompt(request, name, backgroundedName);
287
357
 
288
- this.#startMainQuery(name, label, formatted, this.#config.model);
358
+ this.#sendToMain(name, label, formatted);
289
359
  }
290
360
 
291
- // --- Response delivery ---
361
+ // --- Main session send ---
292
362
 
293
- async #deliverResponse(response: AgentOutput): Promise<void> {
294
- await writeHistoryResult(response);
295
- if (response.action === "send") {
296
- this.#callOnResponse({
297
- message: response.message || "[No output]",
298
- files: response.files,
299
- buttons: response.buttons,
300
- });
301
- } else {
302
- log.debug("Silent response");
303
- }
363
+ #sendToMain(name: string, displayPrompt: string, formatted: string): void {
364
+ const process = this.#ensureMainProcess();
365
+ const sid = process.sessionId;
304
366
 
305
- if (response.backgroundAgents?.length) {
306
- for (const agent of response.backgroundAgents) {
307
- const agentModel = agent.model ?? this.#config.model;
308
- this.#spawnBackground(agent.name, agent.prompt, agentModel);
309
- this.#callOnResponse({ message: `Background agent "${escapeHtml(agent.name)}" started.` });
310
- }
311
- }
312
- }
313
-
314
- #callOnResponse(response: OrchestratorResponse): void {
315
- this.#config.onResponse(response).catch((err) => {
316
- log.error({ err }, "onResponse callback failed");
367
+ const pendingResult = process.send(formatted);
368
+ this.#runningSessions.set(sid, {
369
+ name,
370
+ prompt: displayPrompt,
371
+ model: this.#config.model,
372
+ process,
373
+ pendingResult,
374
+ lastMessageAt: new Date(),
317
375
  });
318
- }
319
-
320
- // --- Main session query ---
321
-
322
- #startMainQuery(name: string, displayPrompt: string, formatted: string, model: string | undefined): void {
323
- const opts = { model };
324
- let query: RunningQuery<AgentOutput>;
325
-
326
- if (this.#mainSessionId && this.#runningSessions.has(this.#mainSessionId)) {
327
- query = this.#claude.forkSession(this.#mainSessionId, formatted, responseResultType, opts);
328
- } else if (this.#mainSessionId) {
329
- query = this.#claude.resumeSession(this.#mainSessionId, formatted, responseResultType, opts);
330
- } else {
331
- query = this.#claude.newSession(formatted, responseResultType, opts);
332
- }
333
376
 
334
- const sid = query.sessionId;
335
- this.#runningSessions.set(sid, { name, prompt: displayPrompt, model, query, lastMessageAt: new Date() });
377
+ log.debug({ name, sessionId: sid }, "Main query sent");
336
378
 
337
- if (sid !== this.#mainSessionId) {
338
- log.info({ oldSessionId: this.#mainSessionId, newSessionId: sid }, "Session updated");
339
- this.#mainSessionId = sid;
340
- saveSessions({ mainSessionId: sid }, this.#config.settingsDir);
341
- }
342
-
343
- log.debug({ name, sessionId: sid }, "Main query started");
344
-
345
- query.result.then(
379
+ pendingResult.then(
346
380
  async ({ value: response }) => {
347
381
  if (!this.#runningSessions.has(sid)) {
348
382
  log.error({ name, sessionId: sid }, "Completed session not in runningSessions — delivering anyway");
@@ -351,11 +385,12 @@ export class Orchestrator {
351
385
  }
352
386
  this.#clearSession(sid);
353
387
 
354
- if (sid === this.#mainSessionId) {
388
+ if (process === this.#mainProcess) {
355
389
  log.debug({ name, sessionId: sid }, "Main query finished, delivering directly");
356
390
  await this.#deliverResponse(response);
357
391
  } else {
358
392
  log.debug({ name, sessionId: sid }, "Non-main query finished, feeding to main session");
393
+ process.kill().catch(() => {});
359
394
  this.#queue.push({ type: "background-agent-result", name, response });
360
395
  }
361
396
  },
@@ -371,57 +406,58 @@ export class Orchestrator {
371
406
  );
372
407
  }
373
408
 
374
- #formatPrompt(request: OrchestratorRequest, name: string, backgroundedEvent?: string): string {
375
- let input: EventInput;
409
+ // --- Response delivery ---
410
+
411
+ async #deliverResponse(response: AgentOutput): Promise<void> {
412
+ await writeHistoryResult(response);
413
+ if (response.action === "send") {
414
+ this.#callOnResponse({
415
+ message: response.message || "[No output]",
416
+ files: response.files,
417
+ buttons: response.buttons,
418
+ });
419
+ } else {
420
+ log.debug("Silent response");
421
+ }
422
+
423
+ if (response.backgroundAgents?.length) {
424
+ for (const agent of response.backgroundAgents) {
425
+ const agentModel = agent.model ?? this.#config.model;
426
+ this.#spawnBackground(agent.name, agent.prompt, agentModel);
427
+ this.#callOnResponse({ message: `Background agent "${escapeHtml(agent.name)}" started.` });
428
+ }
429
+ }
430
+ }
376
431
 
432
+ #callOnResponse(response: OrchestratorResponse): void {
433
+ this.#config.onResponse(response).catch((err) => {
434
+ log.error({ err }, "onResponse callback failed");
435
+ });
436
+ }
437
+
438
+ #formatPrompt(request: OrchestratorRequest, name: string, backgroundedEvent?: string): string {
377
439
  switch (request.type) {
378
440
  case "user":
379
- input = {
380
- name,
381
- type: "user-message",
382
- session: "main",
383
- text: request.message || undefined,
384
- files: request.files,
385
- backgroundedEvent,
386
- };
387
- break;
441
+ return userMessageEvent(name, request.message || "", { files: request.files, backgroundedEvent });
388
442
  case "background-agent-result":
389
- input = {
443
+ return backgroundAgentResultEvent(
390
444
  name,
391
- type: "background-agent-result",
392
- session: "main",
393
- originalEvent: request.name,
394
- result: {
395
- text: request.response.message || "[No output]",
396
- files: request.response.files,
397
- },
398
- backgroundedEvent,
399
- instructions: "Forward this result to the user (action=\"send\"). Summarize or add context from the conversation as appropriate.",
400
- };
401
- break;
445
+ request.name,
446
+ { text: request.response.message || "[No output]", files: request.response.files },
447
+ "Forward this result to the user (action=\"send\"). Summarize or add context from the conversation as appropriate.",
448
+ { backgroundedEvent },
449
+ );
402
450
  case "background-agent-progress":
403
- input = {
451
+ return backgroundAgentProgressEvent(
404
452
  name,
405
- type: "background-agent-progress",
406
- session: "main",
407
- originalEvent: request.name,
408
- progress: request.progress,
409
- instructions: "This is an interim progress update, not a final result. Do not report to the user unless it contains exceptionally important information.",
410
- backgroundedEvent,
411
- };
412
- break;
453
+ request.name,
454
+ request.progress,
455
+ "This is an interim progress update, not a final result. Do not report to the user unless it contains exceptionally important information.",
456
+ { backgroundedEvent },
457
+ );
413
458
  case "button":
414
- input = {
415
- name,
416
- type: "button-click",
417
- session: "main",
418
- button: request.label,
419
- backgroundedEvent,
420
- };
421
- break;
459
+ return buttonClickEvent(name, request.label, { backgroundedEvent });
422
460
  }
423
-
424
- return buildEvent(input);
425
461
  }
426
462
 
427
463
  static #requestLabel(request: OrchestratorRequest): string {
@@ -455,41 +491,37 @@ export class Orchestrator {
455
491
  // --- Background management ---
456
492
 
457
493
  #spawnBackground(name: string, prompt: string, model: string | undefined) {
458
- const formatted = buildEvent({
459
- name,
460
- type: "background-agent-start",
461
- session: "background",
462
- text: prompt,
463
- });
494
+ const formatted = backgroundAgentStartEvent(name, prompt);
464
495
  this.#spawnBackgroundRaw(name, prompt, formatted, model);
465
496
  }
466
497
 
467
498
  #spawnBackgroundRaw(name: string, prompt: string, formatted: string, model: string | undefined) {
468
- const query = this.#mainSessionId
469
- ? this.#claude.forkSession(this.#mainSessionId, formatted, responseResultType, { model })
470
- : this.#claude.newSession(formatted, responseResultType, { model });
471
- this.#registerBackground(name, prompt, model, query);
472
- }
499
+ const process = this.#mainSessionId
500
+ ? this.#claude.forkSession(this.#mainSessionId, responseResultType, { model })
501
+ : this.#claude.newSession(responseResultType, { model });
502
+
503
+ const sid = process.sessionId;
504
+ const pendingResult = process.send(formatted);
473
505
 
474
- #registerBackground(name: string, prompt: string, model: string | undefined, query: RunningQuery<AgentOutput>) {
475
- const sid = query.sessionId;
476
- const info: SessionInfo = { name, prompt, model, query, lastMessageAt: new Date() };
506
+ const info: SessionInfo = { name, prompt, model, process, pendingResult, lastMessageAt: new Date() };
477
507
  this.#runningSessions.set(sid, info);
478
508
 
479
509
  log.debug({ name, sessionId: sid }, "Background session registered");
480
510
 
481
511
  this.#scheduleHealthCheck(sid);
482
512
 
483
- query.result.then(
513
+ pendingResult.then(
484
514
  ({ value: response }) => {
485
515
  if (!this.#runningSessions.has(sid)) return;
486
516
  this.#clearSession(sid);
517
+ process.kill().catch(() => {});
487
518
  log.debug({ name, message: response.message }, "Background session finished");
488
519
  this.#queue.push({ type: "background-agent-result", name, response });
489
520
  },
490
521
  (err) => {
491
522
  if (!this.#runningSessions.has(sid)) return;
492
523
  this.#clearSession(sid);
524
+ process.kill().catch(() => {});
493
525
  log.error({ name, err }, "Background session failed");
494
526
  this.#queue.push({ type: "background-agent-result", name, response: { action: "send", message: `[Error] ${err}`, actionReason: "bg-failed" } });
495
527
  },
@@ -523,17 +555,15 @@ export class Orchestrator {
523
555
 
524
556
  log.debug({ name: info.name, sessionId }, "Running health check");
525
557
 
526
- const prompt = buildEvent({
527
- name: `health-check-${info.name}`,
528
- type: "health-check",
529
- session: "background",
530
- targetEvent: info.name,
531
- instructions: "Report your current status. If your task is complete, set finished=true and provide the full output. If still working, set finished=false and describe current progress in one sentence.",
532
- });
558
+ const prompt = healthCheckEvent(
559
+ `health-check-${info.name}`,
560
+ info.name,
561
+ "Report your current status. If your task is complete, set finished=true and provide the full output. If still working, set finished=false and describe current progress in one sentence.",
562
+ );
533
563
 
534
- let query: RunningQuery<z.infer<typeof healthCheckSchema>>;
564
+ let hcProcess: ClaudeProcess<z.infer<typeof healthCheckSchema>>;
535
565
  try {
536
- query = this.#claude.forkSession(sessionId, prompt, healthCheckResultType, { model: "haiku" });
566
+ hcProcess = this.#claude.forkSession(sessionId, healthCheckResultType, { model: "haiku" });
537
567
  } catch (err) {
538
568
  log.error({ name: info.name, sessionId, err }, "Health check fork failed");
539
569
  this.#scheduleHealthCheck(sessionId);
@@ -541,18 +571,20 @@ export class Orchestrator {
541
571
  }
542
572
 
543
573
  const result = await Promise.race([
544
- query.result.then((r) => r.value),
574
+ hcProcess.send(prompt).then((r) => r.value),
545
575
  new Promise<"timeout">((r) => setTimeout(() => r("timeout"), this.#healthCheckTimeout)),
546
576
  ]);
547
577
 
578
+ // Always kill health check process
579
+ hcProcess.kill().catch(() => {});
580
+
548
581
  // Session may have completed/been killed while health check was running
549
582
  if (!this.#runningSessions.has(sessionId)) return;
550
583
 
551
584
  if (result === "timeout") {
552
585
  log.warn({ name: info.name, sessionId }, "Health check timed out, killing session");
553
- try { await query.kill(); } catch { /* ignore */ }
554
586
  this.#clearSession(sessionId);
555
- try { await info.query.kill(); } catch { /* ignore */ }
587
+ try { await info.process.kill(); } catch { /* ignore */ }
556
588
  this.#callOnResponse({ message: `Agent <b>${escapeHtml(info.name)}</b> appears unresponsive, killed it.` });
557
589
  return;
558
590
  }
@@ -560,7 +592,7 @@ export class Orchestrator {
560
592
  if (result.finished) {
561
593
  log.info({ name: info.name, sessionId }, "Health check: agent reports finished");
562
594
  this.#clearSession(sessionId);
563
- try { await info.query.kill(); } catch { /* ignore */ }
595
+ try { await info.process.kill(); } catch { /* ignore */ }
564
596
  const response = result.output ?? { action: "send" as const, message: "[Agent finished but returned no output]", actionReason: "health-check-finished" };
565
597
  this.#queue.push({ type: "background-agent-result", name: info.name, response });
566
598
  return;