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.
@@ -1,10 +1,11 @@
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";
@@ -92,7 +93,8 @@ interface SessionInfo {
92
93
  name: string;
93
94
  prompt: string;
94
95
  model?: string;
95
- query: RunningQuery<AgentOutput>;
96
+ process: ClaudeProcess<AgentOutput>;
97
+ pendingResult: Promise<QueryResult<AgentOutput>>;
96
98
  lastMessageAt: Date;
97
99
  healthCheckTimer?: Timer;
98
100
  }
@@ -119,6 +121,7 @@ export class Orchestrator {
119
121
  #healthCheckTimeout: number;
120
122
 
121
123
  #mainSessionId: string | undefined;
124
+ #mainProcess: ClaudeProcess<AgentOutput> | null = null;
122
125
  #runningSessions = new Map<string, SessionInfo>();
123
126
  #queue: Queue<OrchestratorRequest>;
124
127
 
@@ -167,14 +170,14 @@ export class Orchestrator {
167
170
  return;
168
171
  }
169
172
  const lines = sessions.map(([sid, s]) => {
170
- const elapsed = Math.round((Date.now() - s.query.startedAt.getTime()) / 1000);
173
+ const elapsed = Math.round((Date.now() - s.process.startedAt.getTime()) / 1000);
171
174
  const isMain = sid === this.#mainSessionId;
172
175
  return isMain
173
176
  ? `▶ ${escapeHtml(s.name)} (${elapsed}s) [main]`
174
177
  : `- ${escapeHtml(s.name)} (${elapsed}s)`;
175
178
  });
176
179
  const buttons: ButtonSpec[] = sessions.map(([sid, s]) => {
177
- const elapsed = Math.round((Date.now() - s.query.startedAt.getTime()) / 1000);
180
+ const elapsed = Math.round((Date.now() - s.process.startedAt.getTime()) / 1000);
178
181
  const text = `${s.name} (${elapsed}s)`.slice(0, 27);
179
182
  return { text, data: `detail:${sid}` };
180
183
  });
@@ -189,7 +192,7 @@ export class Orchestrator {
189
192
  return;
190
193
  }
191
194
 
192
- const elapsed = Math.round((Date.now() - session.query.startedAt.getTime()) / 1000);
195
+ const elapsed = Math.round((Date.now() - session.process.startedAt.getTime()) / 1000);
193
196
  const truncatedPrompt = session.prompt.length > 300 ? `${session.prompt.slice(0, 300)}…` : session.prompt;
194
197
  const isMain = sessionId === this.#mainSessionId;
195
198
  const lines = [
@@ -222,13 +225,13 @@ export class Orchestrator {
222
225
  session.name,
223
226
  `Only consider progress since the "${session.name}" event. Brief status update: done, in progress, remaining. 2-3 sentences max, plain text.`,
224
227
  );
225
- const query = this.#claude.forkSession(
228
+ const peekProcess = this.#claude.forkSession(
226
229
  sessionId,
227
- prompt,
228
230
  textResultType,
229
231
  { model: "haiku" },
230
232
  );
231
- const { value } = await query.result;
233
+ const { value } = await peekProcess.send(prompt);
234
+ peekProcess.kill().catch(() => {});
232
235
  this.#callOnResponse({ message: `<b>[${escapeHtml(session.name)}]</b> ${value || "[No output]"}` });
233
236
  } catch (err) {
234
237
  this.#callOnResponse({ message: `Couldn't peek at ${escapeHtml(session.name)}: ${err}` });
@@ -245,7 +248,7 @@ export class Orchestrator {
245
248
  this.#clearSession(sessionId);
246
249
 
247
250
  try {
248
- await session.query.kill();
251
+ await session.process.kill();
249
252
  } catch (err) {
250
253
  log.error({ err, name: session.name }, "Kill failed");
251
254
  }
@@ -253,102 +256,127 @@ export class Orchestrator {
253
256
  this.#callOnResponse({ message: `Killed <b>${escapeHtml(session.name)}</b>.` });
254
257
  }
255
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
+ }
256
309
 
257
310
  // --- Internal queue handler ---
258
311
 
259
312
  async #handleRequest(request: OrchestratorRequest): Promise<void> {
260
313
  log.debug({ type: request.type }, "Incoming request");
261
314
 
262
- const mainInfo = this.#mainSessionId ? this.#runningSessions.get(this.#mainSessionId) : undefined;
263
315
  let movedToBackground: string | undefined;
264
-
265
- if (mainInfo) {
266
- const elapsed = Date.now() - mainInfo.lastMessageAt.getTime();
267
- if (elapsed >= this.#waitThreshold) {
268
- // Main has been running too long — move to background immediately
269
- log.info({ name: mainInfo.name, sessionId: mainInfo.query.sessionId }, "Moving main session to background (exceeded threshold)");
270
- movedToBackground = mainInfo.prompt;
271
- } else {
272
- // Main started recently — wait for it to finish or threshold
273
- const remaining = this.#waitThreshold - elapsed;
274
- const finished = await Promise.race([
275
- mainInfo.query.result.then(() => true as const, () => true as const),
276
- new Promise<false>((r) => setTimeout(() => r(false), remaining)),
277
- ]);
278
-
279
- if (!finished) {
280
- 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) {
281
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
+ }
282
337
  }
283
- // If finished: completion handler already delivered the result and removed from map.
284
338
  }
285
339
  }
286
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
+
287
352
  await writeHistoryPrompt(request);
288
353
 
289
354
  const label = Orchestrator.#requestLabel(request);
290
355
  const name = generateName(label);
291
- const backgroundedName = movedToBackground ? mainInfo?.name : undefined;
292
356
  const formatted = this.#formatPrompt(request, name, backgroundedName);
293
357
 
294
- this.#startMainQuery(name, label, formatted, this.#config.model);
358
+ this.#sendToMain(name, label, formatted);
295
359
  }
296
360
 
297
- // --- Response delivery ---
361
+ // --- Main session send ---
298
362
 
299
- async #deliverResponse(response: AgentOutput): Promise<void> {
300
- await writeHistoryResult(response);
301
- if (response.action === "send") {
302
- this.#callOnResponse({
303
- message: response.message || "[No output]",
304
- files: response.files,
305
- buttons: response.buttons,
306
- });
307
- } else {
308
- log.debug("Silent response");
309
- }
363
+ #sendToMain(name: string, displayPrompt: string, formatted: string): void {
364
+ const process = this.#ensureMainProcess();
365
+ const sid = process.sessionId;
310
366
 
311
- if (response.backgroundAgents?.length) {
312
- for (const agent of response.backgroundAgents) {
313
- const agentModel = agent.model ?? this.#config.model;
314
- this.#spawnBackground(agent.name, agent.prompt, agentModel);
315
- this.#callOnResponse({ message: `Background agent "${escapeHtml(agent.name)}" started.` });
316
- }
317
- }
318
- }
319
-
320
- #callOnResponse(response: OrchestratorResponse): void {
321
- this.#config.onResponse(response).catch((err) => {
322
- 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(),
323
375
  });
324
- }
325
-
326
- // --- Main session query ---
327
-
328
- #startMainQuery(name: string, displayPrompt: string, formatted: string, model: string | undefined): void {
329
- const opts = { model };
330
- let query: RunningQuery<AgentOutput>;
331
-
332
- if (this.#mainSessionId && this.#runningSessions.has(this.#mainSessionId)) {
333
- query = this.#claude.forkSession(this.#mainSessionId, formatted, responseResultType, opts);
334
- } else if (this.#mainSessionId) {
335
- query = this.#claude.resumeSession(this.#mainSessionId, formatted, responseResultType, opts);
336
- } else {
337
- query = this.#claude.newSession(formatted, responseResultType, opts);
338
- }
339
-
340
- const sid = query.sessionId;
341
- this.#runningSessions.set(sid, { name, prompt: displayPrompt, model, query, lastMessageAt: new Date() });
342
-
343
- if (sid !== this.#mainSessionId) {
344
- log.info({ oldSessionId: this.#mainSessionId, newSessionId: sid }, "Session updated");
345
- this.#mainSessionId = sid;
346
- saveSessions({ mainSessionId: sid }, this.#config.settingsDir);
347
- }
348
376
 
349
- log.debug({ name, sessionId: sid }, "Main query started");
377
+ log.debug({ name, sessionId: sid }, "Main query sent");
350
378
 
351
- query.result.then(
379
+ pendingResult.then(
352
380
  async ({ value: response }) => {
353
381
  if (!this.#runningSessions.has(sid)) {
354
382
  log.error({ name, sessionId: sid }, "Completed session not in runningSessions — delivering anyway");
@@ -357,11 +385,12 @@ export class Orchestrator {
357
385
  }
358
386
  this.#clearSession(sid);
359
387
 
360
- if (sid === this.#mainSessionId) {
388
+ if (process === this.#mainProcess) {
361
389
  log.debug({ name, sessionId: sid }, "Main query finished, delivering directly");
362
390
  await this.#deliverResponse(response);
363
391
  } else {
364
392
  log.debug({ name, sessionId: sid }, "Non-main query finished, feeding to main session");
393
+ process.kill().catch(() => {});
365
394
  this.#queue.push({ type: "background-agent-result", name, response });
366
395
  }
367
396
  },
@@ -377,6 +406,35 @@ export class Orchestrator {
377
406
  );
378
407
  }
379
408
 
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
+ }
431
+
432
+ #callOnResponse(response: OrchestratorResponse): void {
433
+ this.#config.onResponse(response).catch((err) => {
434
+ log.error({ err }, "onResponse callback failed");
435
+ });
436
+ }
437
+
380
438
  #formatPrompt(request: OrchestratorRequest, name: string, backgroundedEvent?: string): string {
381
439
  switch (request.type) {
382
440
  case "user":
@@ -438,31 +496,32 @@ export class Orchestrator {
438
496
  }
439
497
 
440
498
  #spawnBackgroundRaw(name: string, prompt: string, formatted: string, model: string | undefined) {
441
- const query = this.#mainSessionId
442
- ? this.#claude.forkSession(this.#mainSessionId, formatted, responseResultType, { model })
443
- : this.#claude.newSession(formatted, responseResultType, { model });
444
- this.#registerBackground(name, prompt, model, query);
445
- }
499
+ const process = this.#mainSessionId
500
+ ? this.#claude.forkSession(this.#mainSessionId, responseResultType, { model })
501
+ : this.#claude.newSession(responseResultType, { model });
446
502
 
447
- #registerBackground(name: string, prompt: string, model: string | undefined, query: RunningQuery<AgentOutput>) {
448
- const sid = query.sessionId;
449
- const info: SessionInfo = { name, prompt, model, query, lastMessageAt: new Date() };
503
+ const sid = process.sessionId;
504
+ const pendingResult = process.send(formatted);
505
+
506
+ const info: SessionInfo = { name, prompt, model, process, pendingResult, lastMessageAt: new Date() };
450
507
  this.#runningSessions.set(sid, info);
451
508
 
452
509
  log.debug({ name, sessionId: sid }, "Background session registered");
453
510
 
454
511
  this.#scheduleHealthCheck(sid);
455
512
 
456
- query.result.then(
513
+ pendingResult.then(
457
514
  ({ value: response }) => {
458
515
  if (!this.#runningSessions.has(sid)) return;
459
516
  this.#clearSession(sid);
517
+ process.kill().catch(() => {});
460
518
  log.debug({ name, message: response.message }, "Background session finished");
461
519
  this.#queue.push({ type: "background-agent-result", name, response });
462
520
  },
463
521
  (err) => {
464
522
  if (!this.#runningSessions.has(sid)) return;
465
523
  this.#clearSession(sid);
524
+ process.kill().catch(() => {});
466
525
  log.error({ name, err }, "Background session failed");
467
526
  this.#queue.push({ type: "background-agent-result", name, response: { action: "send", message: `[Error] ${err}`, actionReason: "bg-failed" } });
468
527
  },
@@ -502,9 +561,9 @@ export class Orchestrator {
502
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.",
503
562
  );
504
563
 
505
- let query: RunningQuery<z.infer<typeof healthCheckSchema>>;
564
+ let hcProcess: ClaudeProcess<z.infer<typeof healthCheckSchema>>;
506
565
  try {
507
- query = this.#claude.forkSession(sessionId, prompt, healthCheckResultType, { model: "haiku" });
566
+ hcProcess = this.#claude.forkSession(sessionId, healthCheckResultType, { model: "haiku" });
508
567
  } catch (err) {
509
568
  log.error({ name: info.name, sessionId, err }, "Health check fork failed");
510
569
  this.#scheduleHealthCheck(sessionId);
@@ -512,18 +571,20 @@ export class Orchestrator {
512
571
  }
513
572
 
514
573
  const result = await Promise.race([
515
- query.result.then((r) => r.value),
574
+ hcProcess.send(prompt).then((r) => r.value),
516
575
  new Promise<"timeout">((r) => setTimeout(() => r("timeout"), this.#healthCheckTimeout)),
517
576
  ]);
518
577
 
578
+ // Always kill health check process
579
+ hcProcess.kill().catch(() => {});
580
+
519
581
  // Session may have completed/been killed while health check was running
520
582
  if (!this.#runningSessions.has(sessionId)) return;
521
583
 
522
584
  if (result === "timeout") {
523
585
  log.warn({ name: info.name, sessionId }, "Health check timed out, killing session");
524
- try { await query.kill(); } catch { /* ignore */ }
525
586
  this.#clearSession(sessionId);
526
- try { await info.query.kill(); } catch { /* ignore */ }
587
+ try { await info.process.kill(); } catch { /* ignore */ }
527
588
  this.#callOnResponse({ message: `Agent <b>${escapeHtml(info.name)}</b> appears unresponsive, killed it.` });
528
589
  return;
529
590
  }
@@ -531,7 +592,7 @@ export class Orchestrator {
531
592
  if (result.finished) {
532
593
  log.info({ name: info.name, sessionId }, "Health check: agent reports finished");
533
594
  this.#clearSession(sessionId);
534
- try { await info.query.kill(); } catch { /* ignore */ }
595
+ try { await info.process.kill(); } catch { /* ignore */ }
535
596
  const response = result.output ?? { action: "send" as const, message: "[Agent finished but returned no output]", actionReason: "health-check-finished" };
536
597
  this.#queue.push({ type: "background-agent-result", name: info.name, response });
537
598
  return;
@@ -24,6 +24,6 @@ Update macroclaw to the latest version.
24
24
 
25
25
  ## Important
26
26
 
27
- - The `macroclaw service update` command stops the service, installs the latest version, and starts it again. Stopping the service kills all processes in the cgroup — including this Claude Code session.
27
+ - The `macroclaw service update` command stops the user service, installs the latest version, and starts it again. Stopping the service kills all processes in the cgroup — including this Claude Code session.
28
28
  - Do NOT use a background agent — it gets killed along with the main process.
29
29
  - Always run step 4 LAST — everything after it may not execute.
@@ -10,16 +10,15 @@ LOG_FILE="$1"
10
10
 
11
11
  case "$(uname -s)" in
12
12
  Linux)
13
- sudo systemd-run \
13
+ systemd-run --user \
14
14
  --unit="macroclaw-update-$(date -u +%Y%m%dT%H%M%SZ)" \
15
15
  --collect \
16
16
  --no-block \
17
- --property="User=$(id -un)" \
18
- --property="Group=$(id -gn)" \
19
- --setenv="HOME=$HOME" \
20
17
  --setenv="PATH=$PATH" \
21
18
  /bin/bash -lc "exec macroclaw service update > \"$LOG_FILE\" 2>&1"
22
19
 
20
+ # --user: run in user service manager (not system), so the transient unit
21
+ # has access to the user D-Bus session bus and can restart user services.
23
22
  # --collect: automatically remove the transient unit after it finishes.
24
23
  # --no-block: return immediately instead of waiting for the started job.
25
24
  ;;