macroclaw 0.14.0 → 0.16.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.14.0",
3
+ "version": "0.16.0",
4
4
  "description": "Telegram-to-Claude-Code bridge",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/app.test.ts CHANGED
@@ -556,6 +556,29 @@ describe("App", () => {
556
556
  expect((config.claude as Claude & { calls: CallInfo[] }).calls).toHaveLength(0);
557
557
  });
558
558
 
559
+ it("handles detail: callback by routing to orchestrator.handleDetail", async () => {
560
+ const config = makeConfig();
561
+ const app = new App(config);
562
+ const bot = app.bot as any;
563
+ const handler = bot.filterHandlers.get("callback_query:data")![0];
564
+
565
+ const ctx = {
566
+ chat: { id: 12345 },
567
+ callbackQuery: { data: "detail:test-session-123" },
568
+ answerCallbackQuery: mock(async () => {}),
569
+ editMessageReplyMarkup: mock(async () => {}),
570
+ };
571
+
572
+ await handler(ctx);
573
+ await new Promise((r) => setTimeout(r, 50));
574
+
575
+ expect(ctx.answerCallbackQuery).toHaveBeenCalled();
576
+ expect(ctx.editMessageReplyMarkup).toHaveBeenCalledWith({ reply_markup: { inline_keyboard: [[{ text: "✓ Opened", callback_data: "_noop" }]] } });
577
+ const calls = (bot.api.sendMessage as any).mock.calls;
578
+ const text = calls[calls.length - 1][1];
579
+ expect(text).toBe("Agent not found or already finished.");
580
+ });
581
+
559
582
  it("handles peek: callback by routing to orchestrator.handlePeek", async () => {
560
583
  const config = makeConfig();
561
584
  const app = new App(config);
@@ -579,6 +602,29 @@ describe("App", () => {
579
602
  expect(text).toBe("Agent not found or already finished.");
580
603
  });
581
604
 
605
+ it("handles kill: callback by routing to orchestrator.handleKill", async () => {
606
+ const config = makeConfig();
607
+ const app = new App(config);
608
+ const bot = app.bot as any;
609
+ const handler = bot.filterHandlers.get("callback_query:data")![0];
610
+
611
+ const ctx = {
612
+ chat: { id: 12345 },
613
+ callbackQuery: { data: "kill:test-session-123" },
614
+ answerCallbackQuery: mock(async () => {}),
615
+ editMessageReplyMarkup: mock(async () => {}),
616
+ };
617
+
618
+ await handler(ctx);
619
+ await new Promise((r) => setTimeout(r, 50));
620
+
621
+ expect(ctx.answerCallbackQuery).toHaveBeenCalled();
622
+ expect(ctx.editMessageReplyMarkup).toHaveBeenCalledWith({ reply_markup: { inline_keyboard: [[{ text: "✓ Killed", callback_data: "_noop" }]] } });
623
+ const calls = (bot.api.sendMessage as any).mock.calls;
624
+ const text = calls[calls.length - 1][1];
625
+ expect(text).toBe("Agent not found or already finished.");
626
+ });
627
+
582
628
  it("ignores callback_query from unauthorized chats", async () => {
583
629
  const config = makeConfig();
584
630
  const app = new App(config);
package/src/app.ts CHANGED
@@ -149,6 +149,14 @@ export class App {
149
149
  return;
150
150
  }
151
151
 
152
+ if (data.startsWith("detail:")) {
153
+ const sessionId = data.slice(7);
154
+ await ctx.editMessageReplyMarkup({ reply_markup: { inline_keyboard: [[{ text: "✓ Opened", callback_data: "_noop" }]] } });
155
+ log.debug({ sessionId }, "Detail requested");
156
+ this.#orchestrator.handleDetail(sessionId);
157
+ return;
158
+ }
159
+
152
160
  if (data.startsWith("peek:")) {
153
161
  const sessionId = data.slice(5);
154
162
  await ctx.editMessageReplyMarkup({ reply_markup: { inline_keyboard: [[{ text: "✓ Peeked", callback_data: "_noop" }]] } });
@@ -157,6 +165,14 @@ export class App {
157
165
  return;
158
166
  }
159
167
 
168
+ if (data.startsWith("kill:")) {
169
+ const sessionId = data.slice(5);
170
+ await ctx.editMessageReplyMarkup({ reply_markup: { inline_keyboard: [[{ text: "✓ Killed", callback_data: "_noop" }]] } });
171
+ log.debug({ sessionId }, "Kill requested");
172
+ this.#orchestrator.handleKill(sessionId);
173
+ return;
174
+ }
175
+
160
176
  await ctx.editMessageReplyMarkup({ reply_markup: { inline_keyboard: [[{ text: `✓ ${data}`, callback_data: "_noop" }]] } });
161
177
  log.debug({ label: data }, "Button clicked");
162
178
  this.#orchestrator.handleButton(data);
package/src/cli.test.ts CHANGED
@@ -115,7 +115,8 @@ function mockService(overrides?: Record<string, unknown>): SystemServiceManager
115
115
  uninstall: mock(() => {}),
116
116
  start: mock(() => ""),
117
117
  stop: mock(() => {}),
118
- update: mock(() => ""),
118
+ update: mock(() => ({ previousVersion: "0.6.0", currentVersion: "0.7.0" })),
119
+ isRunning: false,
119
120
  status: mock(() => ({ installed: false, running: false, platform: "systemd" as const })),
120
121
  logs: mock(() => "journalctl -u macroclaw -n 50 --no-pager"),
121
122
  ...overrides,
@@ -151,11 +152,26 @@ describe("Cli.service", () => {
151
152
  expect(stop).toHaveBeenCalled();
152
153
  });
153
154
 
154
- it("runs update action", () => {
155
- const update = mock(() => "tail -f /logs");
156
- const cli = new Cli({ systemService: mockService({ update }) });
155
+ it("runs update action — stops and starts when running", () => {
156
+ const stop = mock(() => {});
157
+ const start = mock(() => "tail -f /logs");
158
+ const update = mock(() => ({ previousVersion: "0.6.0", currentVersion: "0.7.0" }));
159
+ const cli = new Cli({ systemService: mockService({ stop, start, update, isRunning: true }) });
157
160
  cli.service("update");
161
+ expect(stop).toHaveBeenCalled();
158
162
  expect(update).toHaveBeenCalled();
163
+ expect(start).toHaveBeenCalled();
164
+ });
165
+
166
+ it("runs update action — skips stop but still starts when not running", () => {
167
+ const stop = mock(() => {});
168
+ const start = mock(() => "tail -f /logs");
169
+ const update = mock(() => ({ previousVersion: "0.6.0", currentVersion: "0.7.0" }));
170
+ const cli = new Cli({ systemService: mockService({ stop, start, update, isRunning: false }) });
171
+ cli.service("update");
172
+ expect(stop).not.toHaveBeenCalled();
173
+ expect(update).toHaveBeenCalled();
174
+ expect(start).toHaveBeenCalled();
159
175
  });
160
176
 
161
177
  it("runs status action", () => {
package/src/cli.ts CHANGED
@@ -55,8 +55,19 @@ export class Cli {
55
55
  console.log("Service stopped.");
56
56
  break;
57
57
  case "update": {
58
- const logCmd = this.#serviceManager.update();
59
- console.log(`Service updated. Check logs:\n ${logCmd}`);
58
+ if (this.#serviceManager.isRunning) {
59
+ this.#serviceManager.stop();
60
+ console.log("Service stopped.");
61
+ }
62
+ const result = this.#serviceManager.update();
63
+ if (result.previousVersion === result.currentVersion) {
64
+ console.log(`macroclaw v${result.currentVersion} (already up to date)`);
65
+ } else {
66
+ console.log(`Updated macroclaw v${result.previousVersion} → v${result.currentVersion}`);
67
+ }
68
+
69
+ const logCmd = this.#serviceManager.start();
70
+ console.log(`Service started. Check logs:\n ${logCmd}`);
60
71
  break;
61
72
  }
62
73
  case "status": {
@@ -559,7 +559,7 @@ describe("Orchestrator", () => {
559
559
  expect(responses[0].message).toBe("No background agents running.");
560
560
  });
561
561
 
562
- it("includes peek buttons and dismiss when agents are running", async () => {
562
+ it("includes detail buttons and dismiss when agents are running", async () => {
563
563
  saveSessions({ mainSessionId: "test-session" }, tmpSettingsDir);
564
564
  const claude = mockClaude((): RunningQuery<unknown> => ({
565
565
  sessionId: `bg-${Date.now()}`,
@@ -578,11 +578,11 @@ describe("Orchestrator", () => {
578
578
  const listResponse = responses[responses.length - 1];
579
579
  expect(listResponse.message).toContain("long-task");
580
580
  expect(listResponse.buttons).toBeDefined();
581
- expect(listResponse.buttons!.length).toBe(2); // 1 peek + dismiss
582
- const peekBtn = listResponse.buttons![0];
583
- expect(typeof peekBtn).toBe("object");
584
- expect((peekBtn as any).data).toMatch(/^peek:/);
585
- expect((peekBtn as any).text).toContain("long-task");
581
+ expect(listResponse.buttons!.length).toBe(2); // 1 detail + dismiss
582
+ const detailBtn = listResponse.buttons![0];
583
+ expect(typeof detailBtn).toBe("object");
584
+ expect((detailBtn as any).data).toMatch(/^detail:/);
585
+ expect((detailBtn as any).text).toContain("long-task");
586
586
  expect(listResponse.buttons![1]).toEqual({ text: "Dismiss", data: "_dismiss" });
587
587
  });
588
588
  });
@@ -624,8 +624,8 @@ describe("Orchestrator", () => {
624
624
  orch.handleBackgroundList();
625
625
  await waitForProcessing();
626
626
  const listResponse = responses[responses.length - 1];
627
- const peekBtn = listResponse.buttons![0] as { text: string; data: string };
628
- const sessionId = peekBtn.data.slice(5); // strip "peek:"
627
+ const detailBtn = listResponse.buttons![0] as { text: string; data: string };
628
+ const sessionId = detailBtn.data.slice(7); // strip "detail:"
629
629
 
630
630
  await orch.handlePeek(sessionId);
631
631
  await waitForProcessing();
@@ -663,8 +663,8 @@ describe("Orchestrator", () => {
663
663
  orch.handleBackgroundList();
664
664
  await waitForProcessing();
665
665
  const listResponse = responses[responses.length - 1];
666
- const peekBtn = listResponse.buttons![0] as { text: string; data: string };
667
- const sessionId = peekBtn.data.slice(5);
666
+ const detailBtn = listResponse.buttons![0] as { text: string; data: string };
667
+ const sessionId = detailBtn.data.slice(7); // strip "detail:"
668
668
 
669
669
  await orch.handlePeek(sessionId);
670
670
  await waitForProcessing();
@@ -674,6 +674,188 @@ describe("Orchestrator", () => {
674
674
  });
675
675
  });
676
676
 
677
+ describe("handleDetail", () => {
678
+ it("returns 'not found' for unknown sessionId", async () => {
679
+ const claude = mockClaude({ action: "send", message: "ok", actionReason: "ok" });
680
+ const { orch, responses } = makeOrchestrator(claude);
681
+
682
+ orch.handleDetail("nonexistent-session");
683
+ await waitForProcessing();
684
+
685
+ expect(responses[0].message).toBe("Agent not found or already finished.");
686
+ });
687
+
688
+ it("shows agent details with peek/kill/dismiss buttons", async () => {
689
+ saveSessions({ mainSessionId: "test-session" }, tmpSettingsDir);
690
+ const claude = mockClaude((): RunningQuery<unknown> => ({
691
+ sessionId: "bg-sid",
692
+ startedAt: new Date(),
693
+ result: new Promise(() => {}),
694
+ kill: mock(async () => {}),
695
+ }));
696
+ const { orch, responses } = makeOrchestrator(claude);
697
+
698
+ orch.handleBackgroundCommand("research pricing");
699
+ await waitForProcessing();
700
+
701
+ orch.handleBackgroundList();
702
+ await waitForProcessing();
703
+ const listResponse = responses[responses.length - 1];
704
+ const detailBtn = listResponse.buttons![0] as { text: string; data: string };
705
+ const sessionId = detailBtn.data.slice(7);
706
+
707
+ orch.handleDetail(sessionId);
708
+ await waitForProcessing();
709
+
710
+ const detailResponse = responses[responses.length - 1];
711
+ expect(detailResponse.message).toContain("research-pricing");
712
+ expect(detailResponse.message).toContain("research pricing");
713
+ expect(detailResponse.message).toContain("default");
714
+ expect(detailResponse.message).toContain("Status: running");
715
+ expect(detailResponse.buttons).toHaveLength(3);
716
+ expect(detailResponse.buttons![0]).toEqual({ text: "Peek", data: `peek:${sessionId}` });
717
+ expect(detailResponse.buttons![1]).toEqual({ text: "Kill", data: `kill:${sessionId}` });
718
+ expect(detailResponse.buttons![2]).toEqual({ text: "Dismiss", data: "_dismiss" });
719
+ });
720
+
721
+ it("truncates prompt at 300 chars", async () => {
722
+ saveSessions({ mainSessionId: "test-session" }, tmpSettingsDir);
723
+ const longPrompt = "a".repeat(500);
724
+ const claude = mockClaude((): RunningQuery<unknown> => ({
725
+ sessionId: "bg-sid",
726
+ startedAt: new Date(),
727
+ result: new Promise(() => {}),
728
+ kill: mock(async () => {}),
729
+ }));
730
+ const { orch, responses } = makeOrchestrator(claude);
731
+
732
+ orch.handleBackgroundCommand(longPrompt);
733
+ await waitForProcessing();
734
+
735
+ orch.handleBackgroundList();
736
+ await waitForProcessing();
737
+ const listResponse = responses[responses.length - 1];
738
+ const detailBtn = listResponse.buttons![0] as { text: string; data: string };
739
+ const sessionId = detailBtn.data.slice(7);
740
+
741
+ orch.handleDetail(sessionId);
742
+ await waitForProcessing();
743
+
744
+ const detailResponse = responses[responses.length - 1];
745
+ // 300 chars + ellipsis
746
+ expect(detailResponse.message).toContain("a".repeat(300));
747
+ expect(detailResponse.message).toContain("…");
748
+ expect(detailResponse.message).not.toContain("a".repeat(301));
749
+ });
750
+
751
+ it("shows model when specified", async () => {
752
+ saveSessions({ mainSessionId: "test-session" }, tmpSettingsDir);
753
+ const claude = mockClaude((): RunningQuery<unknown> => ({
754
+ sessionId: "bg-sid",
755
+ startedAt: new Date(),
756
+ result: new Promise(() => {}),
757
+ kill: mock(async () => {}),
758
+ }));
759
+ const { orch, responses } = makeOrchestrator(claude, { model: "opus" });
760
+
761
+ orch.handleBackgroundCommand("research");
762
+ await waitForProcessing();
763
+
764
+ orch.handleBackgroundList();
765
+ await waitForProcessing();
766
+ const listResponse = responses[responses.length - 1];
767
+ const detailBtn = listResponse.buttons![0] as { text: string; data: string };
768
+ const sessionId = detailBtn.data.slice(7);
769
+
770
+ orch.handleDetail(sessionId);
771
+ await waitForProcessing();
772
+
773
+ const detailResponse = responses[responses.length - 1];
774
+ expect(detailResponse.message).toContain("Model: opus");
775
+ });
776
+ });
777
+
778
+ describe("handleKill", () => {
779
+ it("returns 'not found' for unknown sessionId", async () => {
780
+ const claude = mockClaude({ action: "send", message: "ok", actionReason: "ok" });
781
+ const { orch, responses } = makeOrchestrator(claude);
782
+
783
+ await orch.handleKill("nonexistent-session");
784
+ await waitForProcessing();
785
+
786
+ expect(responses[0].message).toBe("Agent not found or already finished.");
787
+ });
788
+
789
+ it("kills running agent and sends confirmation", async () => {
790
+ saveSessions({ mainSessionId: "test-session" }, tmpSettingsDir);
791
+ const killMock = mock(async () => {});
792
+ const claude = mockClaude((): RunningQuery<unknown> => ({
793
+ sessionId: "bg-sid",
794
+ startedAt: new Date(),
795
+ result: new Promise(() => {}),
796
+ kill: killMock,
797
+ }));
798
+ const { orch, responses } = makeOrchestrator(claude);
799
+
800
+ orch.handleBackgroundCommand("research pricing");
801
+ await waitForProcessing();
802
+
803
+ orch.handleBackgroundList();
804
+ await waitForProcessing();
805
+ const listResponse = responses[responses.length - 1];
806
+ const detailBtn = listResponse.buttons![0] as { text: string; data: string };
807
+ const sessionId = detailBtn.data.slice(7);
808
+
809
+ await orch.handleKill(sessionId);
810
+ await waitForProcessing();
811
+
812
+ expect(killMock).toHaveBeenCalled();
813
+ const killResponse = responses[responses.length - 1];
814
+ expect(killResponse.message).toContain("Killed");
815
+ expect(killResponse.message).toContain("research-pricing");
816
+
817
+ // Agent should be removed from list
818
+ orch.handleBackgroundList();
819
+ await waitForProcessing();
820
+ expect(responses[responses.length - 1].message).toBe("No background agents running.");
821
+ });
822
+
823
+ it("does not feed error back to queue after kill", async () => {
824
+ saveSessions({ mainSessionId: "test-session" }, tmpSettingsDir);
825
+ let rejectBg: (err: Error) => void;
826
+ const bgResult = new Promise<never>((_, r) => { rejectBg = r; });
827
+ const claude = mockClaude((): RunningQuery<unknown> => ({
828
+ sessionId: "bg-sid",
829
+ startedAt: new Date(),
830
+ result: bgResult,
831
+ kill: mock(async () => {}),
832
+ }));
833
+ const { orch, responses } = makeOrchestrator(claude);
834
+
835
+ orch.handleBackgroundCommand("task");
836
+ await waitForProcessing();
837
+
838
+ orch.handleBackgroundList();
839
+ await waitForProcessing();
840
+ const listResponse = responses[responses.length - 1];
841
+ const detailBtn = listResponse.buttons![0] as { text: string; data: string };
842
+ const sessionId = detailBtn.data.slice(7);
843
+
844
+ await orch.handleKill(sessionId);
845
+ await waitForProcessing();
846
+ const countAfterKill = responses.length;
847
+
848
+ // Simulate the bg process rejecting after kill
849
+ rejectBg!(new Error("process killed"));
850
+ await waitForProcessing(100);
851
+
852
+ // No additional error responses should have been added
853
+ // Only the "Killed" message, no "[Error]" from the bg handler
854
+ const newResponses = responses.slice(countAfterKill);
855
+ expect(newResponses.every((r) => !r.message.includes("[Error]"))).toBe(true);
856
+ });
857
+ });
858
+
677
859
  describe("handleBackgroundCommand", () => {
678
860
  it("spawns background agent and sends started message", async () => {
679
861
  saveSessions({ mainSessionId: "test-session" }, tmpSettingsDir);
@@ -67,8 +67,9 @@ function escapeHtml(text: string): string {
67
67
 
68
68
  interface BackgroundInfo {
69
69
  name: string;
70
- sessionId: string;
71
- startTime: Date;
70
+ prompt: string;
71
+ model?: string;
72
+ query: RunningQuery<AgentOutput>;
72
73
  }
73
74
 
74
75
  export interface OrchestratorConfig {
@@ -123,18 +124,42 @@ export class Orchestrator {
123
124
  return;
124
125
  }
125
126
  const lines = agents.map((a) => {
126
- const elapsed = Math.round((Date.now() - a.startTime.getTime()) / 1000);
127
+ const elapsed = Math.round((Date.now() - a.query.startedAt.getTime()) / 1000);
127
128
  return `- ${escapeHtml(a.name)} (${elapsed}s)`;
128
129
  });
129
130
  const buttons: ButtonSpec[] = agents.map((a) => {
130
- const elapsed = Math.round((Date.now() - a.startTime.getTime()) / 1000);
131
+ const elapsed = Math.round((Date.now() - a.query.startedAt.getTime()) / 1000);
131
132
  const text = `${a.name} (${elapsed}s)`.slice(0, 27);
132
- return { text, data: `peek:${a.sessionId}` };
133
+ return { text, data: `detail:${a.query.sessionId}` };
133
134
  });
134
135
  buttons.push({text: "Dismiss", data: "_dismiss"});
135
136
  this.#callOnResponse({ message: lines.join("\n"), buttons });
136
137
  }
137
138
 
139
+ handleDetail(sessionId: string): void {
140
+ const agent = this.#backgroundAgents.get(sessionId);
141
+ if (!agent) {
142
+ this.#callOnResponse({ message: "Agent not found or already finished." });
143
+ return;
144
+ }
145
+
146
+ const elapsed = Math.round((Date.now() - agent.query.startedAt.getTime()) / 1000);
147
+ const truncatedPrompt = agent.prompt.length > 300 ? `${agent.prompt.slice(0, 300)}…` : agent.prompt;
148
+ const lines = [
149
+ `<b>${escapeHtml(agent.name)}</b>`,
150
+ `Prompt: ${escapeHtml(truncatedPrompt)}`,
151
+ `Model: ${agent.model ?? "default"}`,
152
+ `Elapsed: ${elapsed}s`,
153
+ "Status: running",
154
+ ];
155
+ const buttons: ButtonSpec[] = [
156
+ { text: "Peek", data: `peek:${sessionId}` },
157
+ { text: "Kill", data: `kill:${sessionId}` },
158
+ { text: "Dismiss", data: "_dismiss" },
159
+ ];
160
+ this.#callOnResponse({ message: lines.join("\n"), buttons });
161
+ }
162
+
138
163
  async handlePeek(sessionId: string): Promise<void> {
139
164
  const agent = this.#backgroundAgents.get(sessionId);
140
165
  if (!agent) {
@@ -158,6 +183,24 @@ export class Orchestrator {
158
183
  }
159
184
  }
160
185
 
186
+ async handleKill(sessionId: string): Promise<void> {
187
+ const agent = this.#backgroundAgents.get(sessionId);
188
+ if (!agent) {
189
+ this.#callOnResponse({ message: "Agent not found or already finished." });
190
+ return;
191
+ }
192
+
193
+ this.#backgroundAgents.delete(sessionId);
194
+
195
+ try {
196
+ await agent.query.kill();
197
+ } catch (err) {
198
+ log.error({ err, name: agent.name }, "Kill failed");
199
+ }
200
+
201
+ this.#callOnResponse({ message: `Killed <b>${escapeHtml(agent.name)}</b>.` });
202
+ }
203
+
161
204
  handleSessionCommand(): void {
162
205
  this.#callOnResponse({ message: `Session: <code>${this.#mainSessionId ?? "none"}</code>` });
163
206
  }
@@ -298,9 +341,11 @@ export class Orchestrator {
298
341
  const name = request.type === "user" ? request.message.slice(0, 30).replace(/\s+/g, "-")
299
342
  : request.type === "cron" ? `cron-${request.name}`
300
343
  : "task";
344
+ const prompt = this.#formatPrompt(request);
345
+ const model = request.type === "cron" ? (request.model ?? this.#config.model) : this.#config.model;
301
346
  log.info({ name, sessionId: query.sessionId }, "Request backgrounded due to timeout");
302
347
  this.#callOnResponse({ message: "This is taking longer, continuing in the background." });
303
- this.#adoptBackground(name, query.sessionId, query.startedAt, query.result);
348
+ this.#adoptBackground(name, prompt, model, query);
304
349
  return null;
305
350
  }
306
351
 
@@ -327,18 +372,20 @@ export class Orchestrator {
327
372
  ? this.#claude.forkSession(this.#mainSessionId, bgPrompt, responseResultType, { model })
328
373
  : this.#claude.newSession(bgPrompt, responseResultType, { model });
329
374
  const sessionId = query.sessionId;
330
- const info: BackgroundInfo = { name, sessionId, startTime: query.startedAt };
375
+ const info: BackgroundInfo = { name, prompt, model, query };
331
376
  this.#backgroundAgents.set(sessionId, info);
332
377
 
333
378
  log.debug({ name, sessionId }, "Starting background agent");
334
379
 
335
380
  query.result.then(
336
381
  async ({ value: response }) => {
382
+ if (!this.#backgroundAgents.has(sessionId)) return;
337
383
  this.#backgroundAgents.delete(sessionId);
338
384
  log.debug({ name, message: response.message }, "Background agent finished");
339
385
  this.#queue.push({ type: "background-agent-result", name, response });
340
386
  },
341
387
  (err) => {
388
+ if (!this.#backgroundAgents.has(sessionId)) return;
342
389
  this.#backgroundAgents.delete(sessionId);
343
390
  log.error({ name, err }, "Background agent failed");
344
391
  this.#queue.push({ type: "background-agent-result", name, response: { action: "send", message: `[Error] ${err}`, actionReason: "bg-agent-failed" } });
@@ -346,19 +393,22 @@ export class Orchestrator {
346
393
  );
347
394
  }
348
395
 
349
- #adoptBackground(name: string, sessionId: string, startTime: Date, completion: Promise<QueryResult<AgentOutput>>) {
350
- const info: BackgroundInfo = { name, sessionId, startTime };
396
+ #adoptBackground(name: string, prompt: string, model: string | undefined, query: RunningQuery<AgentOutput>) {
397
+ const sessionId = query.sessionId;
398
+ const info: BackgroundInfo = { name, prompt, model, query };
351
399
  this.#backgroundAgents.set(sessionId, info);
352
400
 
353
401
  log.debug({ name, sessionId }, "Adopting backgrounded task");
354
402
 
355
- completion.then(
403
+ query.result.then(
356
404
  ({ value: response }) => {
405
+ if (!this.#backgroundAgents.has(sessionId)) return;
357
406
  this.#backgroundAgents.delete(sessionId);
358
407
  log.debug({ name }, "Adopted task finished");
359
408
  this.#queue.push({ type: "background-agent-result", name, response, sessionId });
360
409
  },
361
410
  (err) => {
411
+ if (!this.#backgroundAgents.has(sessionId)) return;
362
412
  this.#backgroundAgents.delete(sessionId);
363
413
  log.error({ name, err }, "Adopted task failed");
364
414
  this.#queue.push({ type: "background-agent-result", name, response: { action: "send", message: `[Error] ${err}`, actionReason: "deferred-failed" }, sessionId });
@@ -600,23 +600,58 @@ describe("update", () => {
600
600
  );
601
601
  });
602
602
 
603
- it("updates launchd without sudo", () => {
603
+ it("runs bun install without stop/start", () => {
604
604
  const tmpHome = `/tmp/macroclaw-test-updateld-${Date.now()}`;
605
605
  mkdirSync(join(tmpHome, "Library/LaunchAgents"), { recursive: true });
606
606
  writeFileSync(join(tmpHome, "Library/LaunchAgents/com.macroclaw.plist"), "test");
607
607
 
608
608
  mockExecSync.mockImplementation((cmd: string) => {
609
- if (cmd.startsWith("launchctl list ")) return LAUNCHD_RUNNING;
609
+ if (cmd === "bun pm ls -g") return "macroclaw@0.6.0\n";
610
610
  return "";
611
611
  });
612
612
  const mgr = createManager({ platform: "darwin", home: tmpHome });
613
- mgr.update();
614
- expect(mockExecSync).toHaveBeenCalledWith(expect.stringContaining("launchctl unload"), expect.anything());
613
+ const result = mgr.update();
615
614
  expect(mockExecSync).toHaveBeenCalledWith("bun install -g macroclaw@latest", expect.anything());
616
- expect(mockExecSync).toHaveBeenCalledWith(expect.stringContaining("launchctl load"), expect.anything());
617
- for (const call of mockExecSync.mock.calls) {
618
- expect(call[0]).not.toMatch(/^sudo /);
619
- }
615
+ expect(mockExecSync).not.toHaveBeenCalledWith(expect.stringContaining("launchctl"), expect.anything());
616
+ expect(mockExecSync).not.toHaveBeenCalledWith(expect.stringContaining("systemctl"), expect.anything());
617
+ expect(result.previousVersion).toBe("0.6.0");
618
+ expect(result.currentVersion).toBe("0.6.0");
619
+ rmSync(tmpHome, { recursive: true });
620
+ });
621
+
622
+ it("returns different versions when update changes version", () => {
623
+ const tmpHome = `/tmp/macroclaw-test-updatever-${Date.now()}`;
624
+ mkdirSync(join(tmpHome, "Library/LaunchAgents"), { recursive: true });
625
+ writeFileSync(join(tmpHome, "Library/LaunchAgents/com.macroclaw.plist"), "test");
626
+
627
+ let installCalled = false;
628
+ mockExecSync.mockImplementation((cmd: string) => {
629
+ if (cmd.startsWith("launchctl list ")) return LAUNCHD_STOPPED;
630
+ if (cmd === "bun install -g macroclaw@latest") { installCalled = true; return ""; }
631
+ if (cmd === "bun pm ls -g") return installCalled ? "macroclaw@0.7.0\n" : "macroclaw@0.6.0\n";
632
+ return "";
633
+ });
634
+ const mgr = createManager({ platform: "darwin", home: tmpHome });
635
+ const result = mgr.update();
636
+ expect(result.previousVersion).toBe("0.6.0");
637
+ expect(result.currentVersion).toBe("0.7.0");
638
+ rmSync(tmpHome, { recursive: true });
639
+ });
640
+
641
+ it("returns unknown when version query fails", () => {
642
+ const tmpHome = `/tmp/macroclaw-test-updateunk-${Date.now()}`;
643
+ mkdirSync(join(tmpHome, "Library/LaunchAgents"), { recursive: true });
644
+ writeFileSync(join(tmpHome, "Library/LaunchAgents/com.macroclaw.plist"), "test");
645
+
646
+ mockExecSync.mockImplementation((cmd: string) => {
647
+ if (cmd.startsWith("launchctl list ")) return LAUNCHD_STOPPED;
648
+ if (cmd === "bun pm ls -g") throw new Error("command not found");
649
+ return "";
650
+ });
651
+ const mgr = createManager({ platform: "darwin", home: tmpHome });
652
+ const result = mgr.update();
653
+ expect(result.previousVersion).toBe("unknown");
654
+ expect(result.currentVersion).toBe("unknown");
620
655
  rmSync(tmpHome, { recursive: true });
621
656
  });
622
657
  });
@@ -24,6 +24,11 @@ export interface ServiceStatus {
24
24
  uptime?: string;
25
25
  }
26
26
 
27
+ export interface UpdateResult {
28
+ previousVersion: string;
29
+ currentVersion: string;
30
+ }
31
+
27
32
  export class SystemServiceManager {
28
33
  readonly #platform: Platform;
29
34
  readonly #home: string;
@@ -179,25 +184,15 @@ export class SystemServiceManager {
179
184
  log.debug("Service stopped");
180
185
  }
181
186
 
182
- update(): string {
187
+ update(): UpdateResult {
183
188
  this.#requireInstalled();
184
189
 
185
- if (this.#platform === "launchd") {
186
- if (this.isRunning) {
187
- this.#exec(`launchctl unload ${this.serviceFilePath}`);
188
- }
189
- this.#exec("bun install -g macroclaw@latest");
190
- this.#exec(`launchctl load ${this.serviceFilePath}`);
191
- } else {
192
- if (this.isRunning) {
193
- this.#sudo("systemctl stop macroclaw");
194
- }
195
- this.#exec("bun install -g macroclaw@latest");
196
- this.#sudo("systemctl start macroclaw");
197
- }
190
+ const previousVersion = this.#getInstalledVersion();
191
+ this.#exec("bun install -g macroclaw@latest");
192
+ const currentVersion = this.#getInstalledVersion();
198
193
 
199
- log.debug("Service updated (reinstalled, restarted)");
200
- return this.#logTailCommand();
194
+ log.debug({ previousVersion, currentVersion }, "Service updated");
195
+ return { previousVersion, currentVersion };
201
196
  }
202
197
 
203
198
  status(): ServiceStatus {
@@ -261,6 +256,17 @@ export class SystemServiceManager {
261
256
  return binPath;
262
257
  }
263
258
 
259
+
260
+ #getInstalledVersion(): string {
261
+ try {
262
+ const output = this.#exec("bun pm ls -g");
263
+ const match = /macroclaw@(\S+)/.exec(output);
264
+ return match?.[1] ?? "unknown";
265
+ } catch {
266
+ return "unknown";
267
+ }
268
+ }
269
+
264
270
  #requireInstalled(): void {
265
271
  if (!this.isInstalled) {
266
272
  throw new Error("Service not installed. Run `macroclaw service install` first.");