macroclaw 0.28.0 → 0.29.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "macroclaw",
3
- "version": "0.28.0",
3
+ "version": "0.29.0",
4
4
  "description": "Telegram-to-Claude-Code bridge",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -920,6 +920,154 @@ describe("Orchestrator", () => {
920
920
  });
921
921
  });
922
922
 
923
+ describe("health checks", () => {
924
+ it("runs health check after interval and reports finished agent", async () => {
925
+ saveSessions({ mainSessionId: "test-session" }, tmpSettingsDir);
926
+ const { query: bgQuery } = pendingQuery("bg-sid");
927
+
928
+ let callCount = 0;
929
+ const claude = mockClaude((info: CallInfo): RunningQuery<unknown> => {
930
+ callCount++;
931
+ if (callCount === 1) return bgQuery; // background agent spawn
932
+ if (info.prompt.includes("health-check")) {
933
+ return resolvedQuery({
934
+ finished: true,
935
+ output: { action: "send", message: "task complete", actionReason: "done" },
936
+ }, "hc-sid");
937
+ }
938
+ // Main session processes the background-agent-result
939
+ return resolvedQuery({ action: "send", message: "relayed", actionReason: "ok" });
940
+ });
941
+
942
+ const { orch } = makeOrchestrator(claude, {
943
+ healthCheckInterval: 50,
944
+ healthCheckTimeout: 5000,
945
+ });
946
+
947
+ orch.handleBackgroundCommand("long task");
948
+ await waitForProcessing(200);
949
+
950
+ // Health check should have fired, detected finished, killed original, and pushed result
951
+ expect(callCount).toBeGreaterThanOrEqual(2);
952
+ const hcCall = claude.calls.find((c: CallInfo) => c.prompt.includes("health-check"));
953
+ expect(hcCall).toBeDefined();
954
+ expect(hcCall!.model).toBe("haiku");
955
+ });
956
+
957
+ it("reports progress and schedules next check when not finished", async () => {
958
+ saveSessions({ mainSessionId: "test-session" }, tmpSettingsDir);
959
+ const { query: bgQuery } = pendingQuery("bg-sid");
960
+
961
+ let hcCount = 0;
962
+ const claude = mockClaude((info: CallInfo): RunningQuery<unknown> => {
963
+ if (info.prompt.includes("health-check")) {
964
+ hcCount++;
965
+ return resolvedQuery({ finished: false, progress: "still working" }, "hc-sid");
966
+ }
967
+ if (info.prompt.includes("background-agent-result")) {
968
+ return resolvedQuery({ action: "silent", message: "ok", actionReason: "progress" });
969
+ }
970
+ return bgQuery; // background agent spawn
971
+ });
972
+
973
+ const { orch } = makeOrchestrator(claude, {
974
+ healthCheckInterval: 50,
975
+ healthCheckTimeout: 5000,
976
+ });
977
+
978
+ orch.handleBackgroundCommand("long task");
979
+ // Wait for two health check cycles
980
+ await waitForProcessing(250);
981
+
982
+ expect(hcCount).toBeGreaterThanOrEqual(2);
983
+ });
984
+
985
+ it("kills unresponsive agent on health check timeout", async () => {
986
+ saveSessions({ mainSessionId: "test-session" }, tmpSettingsDir);
987
+ const { query: bgQuery } = pendingQuery("bg-sid");
988
+
989
+ const claude = mockClaude((info: CallInfo): RunningQuery<unknown> => {
990
+ if (info.prompt.includes("health-check")) {
991
+ // Never resolves — simulates unresponsive agent
992
+ return { sessionId: "hc-sid", startedAt: new Date(), result: new Promise(() => {}), kill: mock(async () => {}) };
993
+ }
994
+ return bgQuery;
995
+ });
996
+
997
+ const { orch, responses } = makeOrchestrator(claude, {
998
+ healthCheckInterval: 30,
999
+ healthCheckTimeout: 60,
1000
+ });
1001
+
1002
+ orch.handleBackgroundCommand("stuck task");
1003
+ await waitForProcessing(200);
1004
+
1005
+ const killMsg = responses.find((r) => r.message.includes("unresponsive"));
1006
+ expect(killMsg).toBeDefined();
1007
+ });
1008
+
1009
+ it("does not run health checks when interval is 0", async () => {
1010
+ saveSessions({ mainSessionId: "test-session" }, tmpSettingsDir);
1011
+ const { query: bgQuery } = pendingQuery("bg-sid");
1012
+
1013
+ const claude = mockClaude((): RunningQuery<unknown> => bgQuery);
1014
+ const { orch } = makeOrchestrator(claude, { healthCheckInterval: 0 });
1015
+
1016
+ orch.handleBackgroundCommand("some task");
1017
+ await waitForProcessing(100);
1018
+
1019
+ // Only the spawn call, no health check fork
1020
+ expect(claude.calls).toHaveLength(1);
1021
+ });
1022
+
1023
+ it("clears health check timer when session is killed", async () => {
1024
+ saveSessions({ mainSessionId: "test-session" }, tmpSettingsDir);
1025
+ const { query: bgQuery } = pendingQuery("bg-sid");
1026
+
1027
+ let hcCount = 0;
1028
+ const claude = mockClaude((info: CallInfo): RunningQuery<unknown> => {
1029
+ if (info.prompt.includes("health-check")) hcCount++;
1030
+ return bgQuery;
1031
+ });
1032
+
1033
+ const { orch } = makeOrchestrator(claude, { healthCheckInterval: 100 });
1034
+
1035
+ orch.handleBackgroundCommand("killable task");
1036
+ await waitForProcessing();
1037
+
1038
+ // Get the session ID and kill it before health check fires
1039
+ orch.handleKill("bg-sid");
1040
+ await waitForProcessing(200);
1041
+
1042
+ expect(hcCount).toBe(0);
1043
+ });
1044
+
1045
+ it("stops health check if session completes organically before timer", async () => {
1046
+ saveSessions({ mainSessionId: "test-session" }, tmpSettingsDir);
1047
+ const { query: bgQuery, resolve: resolveBg } = pendingQuery("bg-sid");
1048
+
1049
+ let hcCount = 0;
1050
+ let callCount = 0;
1051
+ const claude = mockClaude((info: CallInfo): RunningQuery<unknown> => {
1052
+ callCount++;
1053
+ if (info.prompt.includes("health-check")) { hcCount++; return pendingQuery().query; }
1054
+ if (callCount === 1) return bgQuery;
1055
+ return resolvedQuery({ action: "send", message: "processed", actionReason: "ok" });
1056
+ });
1057
+
1058
+ const { orch } = makeOrchestrator(claude, { healthCheckInterval: 200 });
1059
+
1060
+ orch.handleBackgroundCommand("fast task");
1061
+ await waitForProcessing();
1062
+
1063
+ // Complete before health check fires
1064
+ resolveBg(queryResult({ action: "send", message: "done", actionReason: "done" }));
1065
+ await waitForProcessing(350);
1066
+
1067
+ expect(hcCount).toBe(0);
1068
+ });
1069
+ });
1070
+
923
1071
  describe("onResponse error handling", () => {
924
1072
  it("logs error and does not throw when onResponse callback fails", async () => {
925
1073
  const claude = mockClaude({ action: "send", message: "hello", actionReason: "ok" });
@@ -20,6 +20,8 @@ const log = createLogger("orchestrator");
20
20
  // --- Constants ---
21
21
 
22
22
  const WAIT_THRESHOLD = 60_000;
23
+ const HEALTH_CHECK_INTERVAL_MS = 5 * 60 * 1000;
24
+ const HEALTH_CHECK_TIMEOUT_MS = 120 * 1000;
23
25
 
24
26
  // --- Response schema ---
25
27
 
@@ -40,7 +42,14 @@ const agentOutputSchema = z.object({
40
42
 
41
43
  type AgentOutput = z.infer<typeof agentOutputSchema>;
42
44
 
45
+ const healthCheckSchema = z.object({
46
+ finished: z.boolean().describe("True if the task is complete, false if still working"),
47
+ output: agentOutputSchema.optional().describe("Full output when finished=true"),
48
+ progress: z.string().optional().describe("One-sentence status when finished=false"),
49
+ });
50
+
43
51
  const responseResultType = { type: "object" as const, schema: agentOutputSchema };
52
+ const healthCheckResultType = { type: "object" as const, schema: healthCheckSchema };
44
53
 
45
54
  const textResultType = { type: "text" } as const;
46
55
 
@@ -60,6 +69,7 @@ export interface OrchestratorResponse {
60
69
  type OrchestratorRequest =
61
70
  | { type: "user"; message: string; files?: string[] }
62
71
  | { type: "background-agent-result"; name: string; response: AgentOutput }
72
+ | { type: "background-agent-progress"; name: string; progress: string }
63
73
  | { type: "button"; label: string };
64
74
 
65
75
  function escapeHtml(text: string): string {
@@ -74,6 +84,7 @@ interface SessionInfo {
74
84
  model?: string;
75
85
  query: RunningQuery<AgentOutput>;
76
86
  lastMessageAt: Date;
87
+ healthCheckTimer?: Timer;
77
88
  }
78
89
 
79
90
  export interface OrchestratorConfig {
@@ -84,12 +95,18 @@ export interface OrchestratorConfig {
84
95
  claude?: Claude;
85
96
  /** How long to wait for a running main session before demoting it (ms). Default: 60000 */
86
97
  waitThreshold?: number;
98
+ /** Interval between background agent health checks (ms). Default: 300000. Set to 0 to disable. */
99
+ healthCheckInterval?: number;
100
+ /** Timeout for health check fork responses (ms). Default: 120000 */
101
+ healthCheckTimeout?: number;
87
102
  }
88
103
 
89
104
  export class Orchestrator {
90
105
  #config: Omit<OrchestratorConfig , 'claude'>;
91
106
  #claude: Claude;
92
107
  #waitThreshold: number;
108
+ #healthCheckInterval: number;
109
+ #healthCheckTimeout: number;
93
110
 
94
111
  #mainSessionId: string | undefined;
95
112
  #runningSessions = new Map<string, SessionInfo>();
@@ -99,6 +116,8 @@ export class Orchestrator {
99
116
  this.#config = config;
100
117
  this.#claude = config.claude ?? new Claude({ workspace: config.workspace, systemPrompt: SYSTEM_PROMPT });
101
118
  this.#waitThreshold = config.waitThreshold ?? WAIT_THRESHOLD;
119
+ this.#healthCheckInterval = config.healthCheckInterval ?? HEALTH_CHECK_INTERVAL_MS;
120
+ this.#healthCheckTimeout = config.healthCheckTimeout ?? HEALTH_CHECK_TIMEOUT_MS;
102
121
  this.#queue = new Queue<OrchestratorRequest>();
103
122
  this.#queue.setHandler((request) => this.#handleRequest(request));
104
123
 
@@ -217,7 +236,7 @@ export class Orchestrator {
217
236
  return;
218
237
  }
219
238
 
220
- this.#runningSessions.delete(sessionId);
239
+ this.#clearSession(sessionId);
221
240
 
222
241
  try {
223
242
  await session.query.kill();
@@ -330,7 +349,7 @@ export class Orchestrator {
330
349
  await this.#deliverResponse(response);
331
350
  return;
332
351
  }
333
- this.#runningSessions.delete(sid);
352
+ this.#clearSession(sid);
334
353
 
335
354
  if (sid === this.#mainSessionId) {
336
355
  log.debug({ name, sessionId: sid }, "Main query finished, delivering directly");
@@ -344,7 +363,7 @@ export class Orchestrator {
344
363
  if (!this.#runningSessions.has(sid)) {
345
364
  log.error({ name, sessionId: sid, err }, "Failed session not in runningSessions — delivering error");
346
365
  } else {
347
- this.#runningSessions.delete(sid);
366
+ this.#clearSession(sid);
348
367
  log.error({ name, sessionId: sid, err }, "Main query failed");
349
368
  }
350
369
  await this.#deliverResponse(this.#errorResponse(err));
@@ -380,6 +399,17 @@ export class Orchestrator {
380
399
  instructions: "Forward this result to the user (action=\"send\"). Summarize or add context from the conversation as appropriate.",
381
400
  };
382
401
  break;
402
+ case "background-agent-progress":
403
+ input = {
404
+ 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;
383
413
  case "button":
384
414
  input = {
385
415
  name,
@@ -400,6 +430,8 @@ export class Orchestrator {
400
430
  return request.message;
401
431
  case "background-agent-result":
402
432
  return `bg:${request.name}`;
433
+ case "background-agent-progress":
434
+ return `progress:${request.name}`;
403
435
  case "button":
404
436
  return `btn:${request.label}`;
405
437
  }
@@ -441,23 +473,108 @@ export class Orchestrator {
441
473
 
442
474
  #registerBackground(name: string, prompt: string, model: string | undefined, query: RunningQuery<AgentOutput>) {
443
475
  const sid = query.sessionId;
444
- this.#runningSessions.set(sid, { name, prompt, model, query, lastMessageAt: new Date() });
476
+ const info: SessionInfo = { name, prompt, model, query, lastMessageAt: new Date() };
477
+ this.#runningSessions.set(sid, info);
445
478
 
446
479
  log.debug({ name, sessionId: sid }, "Background session registered");
447
480
 
481
+ this.#scheduleHealthCheck(sid);
482
+
448
483
  query.result.then(
449
484
  ({ value: response }) => {
450
485
  if (!this.#runningSessions.has(sid)) return;
451
- this.#runningSessions.delete(sid);
486
+ this.#clearSession(sid);
452
487
  log.debug({ name, message: response.message }, "Background session finished");
453
488
  this.#queue.push({ type: "background-agent-result", name, response });
454
489
  },
455
490
  (err) => {
456
491
  if (!this.#runningSessions.has(sid)) return;
457
- this.#runningSessions.delete(sid);
492
+ this.#clearSession(sid);
458
493
  log.error({ name, err }, "Background session failed");
459
494
  this.#queue.push({ type: "background-agent-result", name, response: { action: "send", message: `[Error] ${err}`, actionReason: "bg-failed" } });
460
495
  },
461
496
  );
462
497
  }
498
+
499
+ // --- Session cleanup ---
500
+
501
+ #clearSession(sessionId: string) {
502
+ const info = this.#runningSessions.get(sessionId);
503
+ if (info?.healthCheckTimer) clearTimeout(info.healthCheckTimer);
504
+ this.#runningSessions.delete(sessionId);
505
+ }
506
+
507
+ // --- Health checks ---
508
+
509
+ #scheduleHealthCheck(sessionId: string) {
510
+ if (this.#healthCheckInterval <= 0) return;
511
+
512
+ const info = this.#runningSessions.get(sessionId);
513
+ if (!info) return;
514
+
515
+ info.healthCheckTimer = setTimeout(() => {
516
+ this.#runHealthCheck(sessionId);
517
+ }, this.#healthCheckInterval);
518
+ }
519
+
520
+ async #runHealthCheck(sessionId: string): Promise<void> {
521
+ const info = this.#runningSessions.get(sessionId);
522
+ if (!info) return;
523
+
524
+ log.debug({ name: info.name, sessionId }, "Running health check");
525
+
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
+ });
533
+
534
+ let query: RunningQuery<z.infer<typeof healthCheckSchema>>;
535
+ try {
536
+ query = this.#claude.forkSession(sessionId, prompt, healthCheckResultType, { model: "haiku" });
537
+ } catch (err) {
538
+ log.error({ name: info.name, sessionId, err }, "Health check fork failed");
539
+ this.#scheduleHealthCheck(sessionId);
540
+ return;
541
+ }
542
+
543
+ const result = await Promise.race([
544
+ query.result.then((r) => r.value),
545
+ new Promise<"timeout">((r) => setTimeout(() => r("timeout"), this.#healthCheckTimeout)),
546
+ ]);
547
+
548
+ // Session may have completed/been killed while health check was running
549
+ if (!this.#runningSessions.has(sessionId)) return;
550
+
551
+ if (result === "timeout") {
552
+ log.warn({ name: info.name, sessionId }, "Health check timed out, killing session");
553
+ try { await query.kill(); } catch { /* ignore */ }
554
+ this.#clearSession(sessionId);
555
+ try { await info.query.kill(); } catch { /* ignore */ }
556
+ this.#callOnResponse({ message: `Agent <b>${escapeHtml(info.name)}</b> appears unresponsive, killed it.` });
557
+ return;
558
+ }
559
+
560
+ if (result.finished) {
561
+ log.info({ name: info.name, sessionId }, "Health check: agent reports finished");
562
+ this.#clearSession(sessionId);
563
+ try { await info.query.kill(); } catch { /* ignore */ }
564
+ const response = result.output ?? { action: "send" as const, message: "[Agent finished but returned no output]", actionReason: "health-check-finished" };
565
+ this.#queue.push({ type: "background-agent-result", name: info.name, response });
566
+ return;
567
+ }
568
+
569
+ log.debug({ name: info.name, progress: result.progress }, "Health check: still running");
570
+ if (result.progress) {
571
+ this.#queue.push({
572
+ type: "background-agent-progress",
573
+ name: info.name,
574
+ progress: result.progress,
575
+ });
576
+ }
577
+
578
+ this.#scheduleHealthCheck(sessionId);
579
+ }
463
580
  }
@@ -231,6 +231,20 @@ describe("buildEvent", () => {
231
231
  expect(result).not.toContain("<text>");
232
232
  });
233
233
 
234
+ it("builds progress event with progress tag", () => {
235
+ const result = buildEvent({
236
+ name: "progress-research",
237
+ type: "background-agent-progress",
238
+ session: "main",
239
+ originalEvent: "research",
240
+ progress: "indexing 500 documents",
241
+ });
242
+ expect(result).toContain('type="background-agent-progress"');
243
+ expect(result).toContain('<original-event name="research" />');
244
+ expect(result).toContain("<progress>indexing 500 documents</progress>");
245
+ expect(result).not.toContain("<result>");
246
+ });
247
+
234
248
  it("includes instructions in event", () => {
235
249
  const result = buildEvent({
236
250
  name: "bg-research",
package/src/prompts.ts CHANGED
@@ -23,7 +23,9 @@ Event format: every incoming message is wrapped in an <event> XML block. Attribu
23
23
  - schedule-trigger — automated scheduled task. Contains <schedule> with name and optional missed-by/scheduled-at attributes. Prefer action="silent" when nothing noteworthy.
24
24
  - background-agent-start — you are a background agent. Complete the task in <text> and return a result.
25
25
  - background-agent-result — a background agent has finished. Contains <original-event name="..." /> linking to the agent that produced it, and a <result> block with <text> and optional <files>. Always use action="send" — the user expects to see the outcome. Summarize, relay, or add additional context from the conversation as appropriate.
26
+ - background-agent-progress — interim progress update from a still-running background agent. Contains <original-event name="..." /> and a <progress> element. This is NOT a final result. Do not report to the user unless it contains exceptionally important information (errors, blockers, urgent findings). Keep this context in mind — if the user later asks about progress of a background task, use the latest progress update to answer.
26
27
  - peek — status check on a running session. Contains <target-event name="..." /> identifying the event being peeked at. Only consider progress since that event started. Respond with a brief status update (2-3 sentences): what has been done, what's happening now, what's remaining. Return plain text, not structured output.
28
+ - health-check — automated status check on a background agent. Contains <target-event name="..." />. Report whether the task is complete or still in progress.
27
29
  - session — "main" (primary conversation) or "background" (background agent).
28
30
 
29
31
  Backgrounded events: when a new message arrives while a previous task is still running, \
@@ -40,6 +42,7 @@ Inner elements:
40
42
  - <backgrounded-event name="..." /> — a previously running task moved to background (see above).
41
43
  - <original-event name="..." /> — in background-agent-result, links to the agent that produced the result.
42
44
  - <target-event name="..." /> — in peek, identifies the event being checked on.
45
+ - <progress> — interim status from a still-running background agent.
43
46
  - <result> — wraps the output from a completed background agent. Contains <text> and optional <files>.
44
47
  - <instructions> — inline guidance for how to handle this specific event. Always follow these instructions.
45
48
 
@@ -76,7 +79,9 @@ export type EventType =
76
79
  | "schedule-trigger"
77
80
  | "background-agent-start"
78
81
  | "background-agent-result"
79
- | "peek";
82
+ | "background-agent-progress"
83
+ | "peek"
84
+ | "health-check";
80
85
 
81
86
  export interface EventInput {
82
87
  name: string;
@@ -90,6 +95,7 @@ export interface EventInput {
90
95
  originalEvent?: string;
91
96
  targetEvent?: string;
92
97
  instructions?: string;
98
+ progress?: string;
93
99
  result?: { text: string; files?: string[] };
94
100
  }
95
101
 
@@ -121,6 +127,11 @@ export function buildEvent(input: EventInput): string {
121
127
  lines.push(`<target-event name="${escapeXml(input.targetEvent)}" />`);
122
128
  }
123
129
 
130
+ // Progress (for background-agent-progress)
131
+ if (input.progress) {
132
+ lines.push(`<progress>${escapeXml(input.progress)}</progress>`);
133
+ }
134
+
124
135
  // Result block (for background-agent-result)
125
136
  if (input.result) {
126
137
  lines.push("<result>");