macroclaw 0.15.0 → 0.17.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.15.0",
3
+ "version": "0.17.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);
@@ -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) {
@@ -145,9 +170,10 @@ export class Orchestrator {
145
170
  this.#callOnResponse({ message: `Peeking at <b>${escapeHtml(agent.name)}</b>...` });
146
171
 
147
172
  try {
173
+ const startedAt = agent.query.startedAt.toISOString();
148
174
  const query = this.#claude.forkSession(
149
175
  sessionId,
150
- "Give a brief status update: what has been done so far, what's currently happening, and what's remaining. 2-3 sentences max.",
176
+ `This session started at ${startedAt}. Only consider events after that time. Give a brief status update: what has been done so far, what's currently happening, and what's remaining. 2-3 sentences max.`,
151
177
  textResultType,
152
178
  { model: "haiku" },
153
179
  );
@@ -158,6 +184,24 @@ export class Orchestrator {
158
184
  }
159
185
  }
160
186
 
187
+ async handleKill(sessionId: string): Promise<void> {
188
+ const agent = this.#backgroundAgents.get(sessionId);
189
+ if (!agent) {
190
+ this.#callOnResponse({ message: "Agent not found or already finished." });
191
+ return;
192
+ }
193
+
194
+ this.#backgroundAgents.delete(sessionId);
195
+
196
+ try {
197
+ await agent.query.kill();
198
+ } catch (err) {
199
+ log.error({ err, name: agent.name }, "Kill failed");
200
+ }
201
+
202
+ this.#callOnResponse({ message: `Killed <b>${escapeHtml(agent.name)}</b>.` });
203
+ }
204
+
161
205
  handleSessionCommand(): void {
162
206
  this.#callOnResponse({ message: `Session: <code>${this.#mainSessionId ?? "none"}</code>` });
163
207
  }
@@ -298,9 +342,11 @@ export class Orchestrator {
298
342
  const name = request.type === "user" ? request.message.slice(0, 30).replace(/\s+/g, "-")
299
343
  : request.type === "cron" ? `cron-${request.name}`
300
344
  : "task";
345
+ const prompt = this.#formatPrompt(request);
346
+ const model = request.type === "cron" ? (request.model ?? this.#config.model) : this.#config.model;
301
347
  log.info({ name, sessionId: query.sessionId }, "Request backgrounded due to timeout");
302
348
  this.#callOnResponse({ message: "This is taking longer, continuing in the background." });
303
- this.#adoptBackground(name, query.sessionId, query.startedAt, query.result);
349
+ this.#adoptBackground(name, prompt, model, query);
304
350
  return null;
305
351
  }
306
352
 
@@ -327,18 +373,20 @@ export class Orchestrator {
327
373
  ? this.#claude.forkSession(this.#mainSessionId, bgPrompt, responseResultType, { model })
328
374
  : this.#claude.newSession(bgPrompt, responseResultType, { model });
329
375
  const sessionId = query.sessionId;
330
- const info: BackgroundInfo = { name, sessionId, startTime: query.startedAt };
376
+ const info: BackgroundInfo = { name, prompt, model, query };
331
377
  this.#backgroundAgents.set(sessionId, info);
332
378
 
333
379
  log.debug({ name, sessionId }, "Starting background agent");
334
380
 
335
381
  query.result.then(
336
382
  async ({ value: response }) => {
383
+ if (!this.#backgroundAgents.has(sessionId)) return;
337
384
  this.#backgroundAgents.delete(sessionId);
338
385
  log.debug({ name, message: response.message }, "Background agent finished");
339
386
  this.#queue.push({ type: "background-agent-result", name, response });
340
387
  },
341
388
  (err) => {
389
+ if (!this.#backgroundAgents.has(sessionId)) return;
342
390
  this.#backgroundAgents.delete(sessionId);
343
391
  log.error({ name, err }, "Background agent failed");
344
392
  this.#queue.push({ type: "background-agent-result", name, response: { action: "send", message: `[Error] ${err}`, actionReason: "bg-agent-failed" } });
@@ -346,19 +394,22 @@ export class Orchestrator {
346
394
  );
347
395
  }
348
396
 
349
- #adoptBackground(name: string, sessionId: string, startTime: Date, completion: Promise<QueryResult<AgentOutput>>) {
350
- const info: BackgroundInfo = { name, sessionId, startTime };
397
+ #adoptBackground(name: string, prompt: string, model: string | undefined, query: RunningQuery<AgentOutput>) {
398
+ const sessionId = query.sessionId;
399
+ const info: BackgroundInfo = { name, prompt, model, query };
351
400
  this.#backgroundAgents.set(sessionId, info);
352
401
 
353
402
  log.debug({ name, sessionId }, "Adopting backgrounded task");
354
403
 
355
- completion.then(
404
+ query.result.then(
356
405
  ({ value: response }) => {
406
+ if (!this.#backgroundAgents.has(sessionId)) return;
357
407
  this.#backgroundAgents.delete(sessionId);
358
408
  log.debug({ name }, "Adopted task finished");
359
409
  this.#queue.push({ type: "background-agent-result", name, response, sessionId });
360
410
  },
361
411
  (err) => {
412
+ if (!this.#backgroundAgents.has(sessionId)) return;
362
413
  this.#backgroundAgents.delete(sessionId);
363
414
  log.error({ name, err }, "Adopted task failed");
364
415
  this.#queue.push({ type: "background-agent-result", name, response: { action: "send", message: `[Error] ${err}`, actionReason: "deferred-failed" }, sessionId });