macroclaw 0.4.0 → 0.6.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/README.md CHANGED
@@ -5,52 +5,6 @@ Telegram-to-Claude-Code bridge. Bun + Grammy.
5
5
  Uses the Claude Code CLI (`claude -p`) rather than the Agent SDK to avoid any possible
6
6
  ToS issues with using a Claude subscription programmatically.
7
7
 
8
- ## Vision
9
-
10
- Macroclaw is a minimal bridge between Telegram and Claude Code. It handles the parts
11
- that a Claude session can't: receiving messages, managing processes, scheduling tasks,
12
- and delivering responses.
13
-
14
- Everything else — personality, memory, skills, behavior, conventions — lives in the
15
- workspace. The platform stays small so the workspace can be infinitely customizable
16
- without touching platform code.
17
-
18
- ## Architecture
19
-
20
- Macroclaw follows a **thin platform, rich workspace** design:
21
-
22
- **Platform** (this repo) — the runtime bridge:
23
- - Telegram bot connection and message routing
24
- - Claude Code process orchestration and session management
25
- - Background agent spawning and lifecycle
26
- - Cron scheduler (reads job definitions from workspace)
27
- - Message queue (FIFO, serial processing)
28
- - Timeout management and auto-retry
29
-
30
- **Workspace** — the intelligence layer, initialized from [`workspace-template/`](workspace-template/):
31
- - [`CLAUDE.md`](workspace-template/CLAUDE.md) — agent behavior, conventions, response style
32
- - [`.claude/skills/`](workspace-template/.claude/skills/) — teachable capabilities
33
- - [`.macroclaw/cron.json`](workspace-template/.macroclaw/cron.json) — scheduled job definitions
34
- - [`MEMORY.md`](workspace-template/MEMORY.md) — persistent memory
35
-
36
- ### Where does a new feature belong?
37
-
38
- **Platform** when it:
39
- - Requires external API access (Telegram, future integrations)
40
- - Manages processes (spawning Claude, background agents, timeouts)
41
- - Operates outside Claude sessions (cron scheduling, message queuing)
42
- - Is a security boundary (chat authorization, workspace isolation)
43
- - Is bootstrap logic (workspace initialization)
44
-
45
- **Workspace** when it:
46
- - Defines agent behavior or personality
47
- - Is a convention Claude can follow via instructions
48
- - Can be implemented as a skill
49
- - Is data that Claude reads/writes (memory, tasks, cron definitions)
50
- - Is a formatting or response style rule
51
-
52
- > **Litmus test:** Could this feature work if you just wrote instructions in CLAUDE.md and/or created a skill? If yes → workspace. If no → platform.
53
-
54
8
  ## Security Model
55
9
 
56
10
  Macroclaw runs with `dangerouslySkipPermissions` enabled. This is intentional — the bot
@@ -138,6 +92,52 @@ bun test # run tests (100% coverage enforced)
138
92
  bun run claude # open Claude Code CLI in current main session
139
93
  ```
140
94
 
95
+ ## Vision
96
+
97
+ Macroclaw is a minimal bridge between Telegram and Claude Code. It handles the parts
98
+ that a Claude session can't: receiving messages, managing processes, scheduling tasks,
99
+ and delivering responses.
100
+
101
+ Everything else — personality, memory, skills, behavior, conventions — lives in the
102
+ workspace. The platform stays small so the workspace can be infinitely customizable
103
+ without touching platform code.
104
+
105
+ ## Architecture
106
+
107
+ Macroclaw follows a **thin platform, rich workspace** design:
108
+
109
+ **Platform** (this repo) — the runtime bridge:
110
+ - Telegram bot connection and message routing
111
+ - Claude Code process orchestration and session management
112
+ - Background agent spawning and lifecycle
113
+ - Cron scheduler (reads job definitions from workspace)
114
+ - Message queue (FIFO, serial processing)
115
+ - Timeout management and auto-retry
116
+
117
+ **Workspace** — the intelligence layer, initialized from [`workspace-template/`](workspace-template/):
118
+ - [`CLAUDE.md`](workspace-template/CLAUDE.md) — agent behavior, conventions, response style
119
+ - [`.claude/skills/`](workspace-template/.claude/skills/) — teachable capabilities
120
+ - [`.macroclaw/cron.json`](workspace-template/.macroclaw/cron.json) — scheduled job definitions
121
+ - [`MEMORY.md`](workspace-template/MEMORY.md) — persistent memory
122
+
123
+ ### Where does a new feature belong?
124
+
125
+ **Platform** when it:
126
+ - Requires external API access (Telegram, future integrations)
127
+ - Manages processes (spawning Claude, background agents, timeouts)
128
+ - Operates outside Claude sessions (cron scheduling, message queuing)
129
+ - Is a security boundary (chat authorization, workspace isolation)
130
+ - Is bootstrap logic (workspace initialization)
131
+
132
+ **Workspace** when it:
133
+ - Defines agent behavior or personality
134
+ - Is a convention Claude can follow via instructions
135
+ - Can be implemented as a skill
136
+ - Is data that Claude reads/writes (memory, tasks, cron definitions)
137
+ - Is a formatting or response style rule
138
+
139
+ > **Litmus test:** Could this feature work if you just wrote instructions in CLAUDE.md and/or created a skill? If yes → workspace. If no → platform.
140
+
141
141
  ## License
142
142
 
143
143
  MIT
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "macroclaw",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "description": "Telegram-to-Claude-Code bridge",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/app.test.ts CHANGED
@@ -516,6 +516,51 @@ describe("App", () => {
516
516
  expect(opts.prompt).toBe('[Context: button-click] User tapped "Yes"');
517
517
  });
518
518
 
519
+ it("handles _dismiss callback by removing reply markup", async () => {
520
+ const config = makeConfig();
521
+ const app = new App(config);
522
+ const bot = app.bot as any;
523
+ const handler = bot.filterHandlers.get("callback_query:data")![0];
524
+
525
+ const ctx = {
526
+ chat: { id: 12345 },
527
+ callbackQuery: { data: "_dismiss" },
528
+ answerCallbackQuery: mock(async () => {}),
529
+ editMessageReplyMarkup: mock(async () => {}),
530
+ };
531
+
532
+ await handler(ctx);
533
+ await new Promise((r) => setTimeout(r, 50));
534
+
535
+ expect(ctx.answerCallbackQuery).toHaveBeenCalled();
536
+ expect(ctx.editMessageReplyMarkup).toHaveBeenCalledWith({ reply_markup: undefined });
537
+ expect((config.claude as any).run).not.toHaveBeenCalled();
538
+ });
539
+
540
+ it("handles peek: callback by routing to orchestrator.handlePeek", async () => {
541
+ const config = makeConfig();
542
+ const app = new App(config);
543
+ const bot = app.bot as any;
544
+ const handler = bot.filterHandlers.get("callback_query:data")![0];
545
+
546
+ const ctx = {
547
+ chat: { id: 12345 },
548
+ callbackQuery: { data: "peek:test-session-123" },
549
+ answerCallbackQuery: mock(async () => {}),
550
+ editMessageReplyMarkup: mock(async () => {}),
551
+ };
552
+
553
+ await handler(ctx);
554
+ await new Promise((r) => setTimeout(r, 50));
555
+
556
+ expect(ctx.answerCallbackQuery).toHaveBeenCalled();
557
+ expect(ctx.editMessageReplyMarkup).toHaveBeenCalledWith({ reply_markup: { inline_keyboard: [[{ text: "✓ Peeked", callback_data: "_noop" }]] } });
558
+ // handlePeek sends "Agent not found" since no active agents
559
+ const calls = (bot.api.sendMessage as any).mock.calls;
560
+ const text = calls[calls.length - 1][1];
561
+ expect(text).toBe("Agent not found or already finished.");
562
+ });
563
+
519
564
  it("ignores callback_query from unauthorized chats", async () => {
520
565
  const config = makeConfig();
521
566
  const app = new App(config);
package/src/app.ts CHANGED
@@ -140,12 +140,26 @@ export class App {
140
140
 
141
141
  this.#bot.on("callback_query:data", async (ctx) => {
142
142
  await ctx.answerCallbackQuery();
143
- const label = ctx.callbackQuery.data;
144
- if (label === "_noop") return;
145
- await ctx.editMessageReplyMarkup({ reply_markup: { inline_keyboard: [[{ text: `✓ ${label}`, callback_data: "_noop" }]] } });
143
+ const data = ctx.callbackQuery.data;
144
+ if (data === "_noop") return;
146
145
  if (ctx.chat?.id.toString() !== this.#config.authorizedChatId) return;
147
- log.debug({ label }, "Button clicked");
148
- this.#orchestrator.handleButton(label);
146
+
147
+ if (data === "_dismiss") {
148
+ await ctx.editMessageReplyMarkup({ reply_markup: undefined });
149
+ return;
150
+ }
151
+
152
+ if (data.startsWith("peek:")) {
153
+ const sessionId = data.slice(5);
154
+ await ctx.editMessageReplyMarkup({ reply_markup: { inline_keyboard: [[{ text: "✓ Peeked", callback_data: "_noop" }]] } });
155
+ log.debug({ sessionId }, "Peek requested");
156
+ this.#orchestrator.handlePeek(sessionId);
157
+ return;
158
+ }
159
+
160
+ await ctx.editMessageReplyMarkup({ reply_markup: { inline_keyboard: [[{ text: `✓ ${data}`, callback_data: "_noop" }]] } });
161
+ log.debug({ label: data }, "Button clicked");
162
+ this.#orchestrator.handleButton(data);
149
163
  });
150
164
 
151
165
  this.#bot.on("message:text", (ctx) => {
@@ -54,7 +54,7 @@ describe("claude CLI structured output", () => {
54
54
  const claude = new Claude({ workspace: WORKSPACE, jsonSchema: SIMPLE_SCHEMA });
55
55
  const result = await runSync(claude, {
56
56
  prompt: "Say hello",
57
- sessionFlag: "--session-id",
57
+ resume: false,
58
58
  sessionId: randomUUID(),
59
59
  model: "haiku",
60
60
  });
@@ -67,7 +67,7 @@ describe("claude CLI structured output", () => {
67
67
  const claude = new Claude({ workspace: WORKSPACE, jsonSchema: SIMPLE_SCHEMA });
68
68
  const result = await runSync(claude, {
69
69
  prompt: "Say hello",
70
- sessionFlag: "--session-id",
70
+ resume: false,
71
71
  sessionId: randomUUID(),
72
72
  model: "haiku",
73
73
  systemPrompt: "You are a helpful assistant. This is a direct message from the user.",
@@ -81,7 +81,7 @@ describe("claude CLI structured output", () => {
81
81
  const claude = new Claude({ workspace: WORKSPACE, jsonSchema: FULL_SCHEMA });
82
82
  const result = await runSync(claude, {
83
83
  prompt: "Say hello",
84
- sessionFlag: "--session-id",
84
+ resume: false,
85
85
  sessionId: randomUUID(),
86
86
  model: "haiku",
87
87
  systemPrompt: "You are a helpful assistant. This is a direct message from the user.",
@@ -96,7 +96,7 @@ describe("claude CLI structured output", () => {
96
96
  const claude = new Claude({ workspace, jsonSchema: FULL_SCHEMA });
97
97
  const result = await runSync(claude, {
98
98
  prompt: "Say hello",
99
- sessionFlag: "--session-id",
99
+ resume: false,
100
100
  sessionId: randomUUID(),
101
101
  model: "sonnet",
102
102
  systemPrompt: `You are an AI assistant running inside macroclaw. This is a direct message from the user.`,
@@ -45,7 +45,6 @@ function makeClaude() {
45
45
  function opts(overrides?: Partial<ClaudeRunOptions>): ClaudeRunOptions {
46
46
  return {
47
47
  prompt: "test message",
48
- sessionFlag: "--session-id",
49
48
  sessionId: "sid-1",
50
49
  ...overrides,
51
50
  };
@@ -58,7 +57,7 @@ async function runSync(claude: Claude, options: ClaudeRunOptions): Promise<Claud
58
57
  }
59
58
 
60
59
  describe("Claude", () => {
61
- it("passes --session-id flag when given", async () => {
60
+ it("passes --session-id flag when resume is false/unset", async () => {
62
61
  mockSpawn({ stdout: jsonResult({ action: "send", message: "Hello" }), exitCode: 0 });
63
62
  const claude = makeClaude();
64
63
  const result = await runSync(claude, opts());
@@ -69,10 +68,10 @@ describe("Claude", () => {
69
68
  );
70
69
  });
71
70
 
72
- it("passes --resume flag when given", async () => {
71
+ it("passes --resume flag when resume is true", async () => {
73
72
  mockSpawn({ stdout: jsonResult({ action: "send" }), exitCode: 0 });
74
73
  const claude = makeClaude();
75
- await claude.run(opts({ sessionFlag: "--resume", sessionId: "sid-2" }));
74
+ await claude.run(opts({ resume: true, sessionId: "sid-2" }));
76
75
  expect(Bun.spawn).toHaveBeenCalledWith(
77
76
  expect.arrayContaining(["claude", "-p", "--resume", "sid-2"]),
78
77
  expect.objectContaining({ cwd: TEST_WORKSPACE }),
@@ -102,7 +101,7 @@ describe("Claude", () => {
102
101
  it("passes --fork-session when forkSession is true", async () => {
103
102
  mockSpawn({ stdout: jsonResult({ action: "send" }), exitCode: 0 });
104
103
  const claude = makeClaude();
105
- await claude.run(opts({ sessionFlag: "--resume", sessionId: "sid-fork", forkSession: true, prompt: "bg task" }));
104
+ await claude.run(opts({ resume: true, sessionId: "sid-fork", forkSession: true, prompt: "bg task" }));
106
105
  expect(Bun.spawn).toHaveBeenCalledWith(
107
106
  expect.arrayContaining(["--resume", "sid-fork", "--fork-session"]),
108
107
  expect.objectContaining({ cwd: TEST_WORKSPACE }),
@@ -125,6 +124,25 @@ describe("Claude", () => {
125
124
  expect(args).not.toContain("--append-system-prompt");
126
125
  });
127
126
 
127
+ it("omits --json-schema when plainText is true", async () => {
128
+ const envelope = JSON.stringify({ type: "result", result: "status update", duration_ms: 100, total_cost_usd: 0.01, session_id: "sid-pt" });
129
+ mockSpawn({ stdout: envelope, exitCode: 0 });
130
+ const claude = makeClaude();
131
+ const result = await runSync(claude, opts({ sessionId: "sid-pt", plainText: true }));
132
+ const args = (Bun.spawn as any).mock.calls[0][0] as string[];
133
+ expect(args).not.toContain("--json-schema");
134
+ expect(args).toContain("--output-format");
135
+ expect(result.result).toBe("status update");
136
+ });
137
+
138
+ it("includes --json-schema when plainText is not set", async () => {
139
+ mockSpawn({ stdout: jsonResult({ action: "send" }), exitCode: 0 });
140
+ const claude = makeClaude();
141
+ await claude.run(opts({ sessionId: "sid-schema" }));
142
+ const args = (Bun.spawn as any).mock.calls[0][0] as string[];
143
+ expect(args).toContain("--json-schema");
144
+ });
145
+
128
146
  it("throws ClaudeProcessError on non-zero exit", async () => {
129
147
  mockSpawn({ stderr: "something went wrong", exitCode: 1 });
130
148
  const claude = makeClaude();
@@ -179,7 +197,7 @@ describe("Claude", () => {
179
197
  it("returns structured_output from successful response", async () => {
180
198
  mockSpawn({ stdout: jsonResult({ action: "silent", actionReason: "no new results" }), exitCode: 0 });
181
199
  const claude = makeClaude();
182
- const result = await runSync(claude, opts({ sessionFlag: "--resume", sessionId: "sid-8" }));
200
+ const result = await runSync(claude, opts({ resume: true, sessionId: "sid-8" }));
183
201
  expect(result.structuredOutput).toEqual({ action: "silent", actionReason: "no new results" });
184
202
  expect(result.sessionId).toBe("test-session-id");
185
203
  });
@@ -188,7 +206,7 @@ describe("Claude", () => {
188
206
  const envelope = JSON.stringify({ type: "result", result: "plain text", duration_ms: 100, total_cost_usd: 0.01, session_id: "sid-abc" });
189
207
  mockSpawn({ stdout: envelope, exitCode: 0 });
190
208
  const claude = makeClaude();
191
- const result = await runSync(claude, opts({ sessionFlag: "--resume", sessionId: "sid-9" }));
209
+ const result = await runSync(claude, opts({ resume: true, sessionId: "sid-9" }));
192
210
  expect(result.structuredOutput).toBeNull();
193
211
  expect(result.result).toBe("plain text");
194
212
  expect(result.sessionId).toBe("sid-abc");
@@ -198,14 +216,14 @@ describe("Claude", () => {
198
216
  const envelope = JSON.stringify({ type: "result", result: "text", duration_ms: 100, total_cost_usd: 0.01 });
199
217
  mockSpawn({ stdout: envelope, exitCode: 0 });
200
218
  const claude = makeClaude();
201
- const result = await runSync(claude, opts({ sessionFlag: "--resume", sessionId: "sid-9c" }));
219
+ const result = await runSync(claude, opts({ resume: true, sessionId: "sid-9c" }));
202
220
  expect(result.sessionId).toBe("");
203
221
  });
204
222
 
205
223
  it("returns result from envelope", async () => {
206
224
  mockSpawn({ stdout: jsonResult({ action: "send" }), exitCode: 0 });
207
225
  const claude = makeClaude();
208
- const result = await runSync(claude, opts({ sessionFlag: "--resume", sessionId: "sid-9b" }));
226
+ const result = await runSync(claude, opts({ resume: true, sessionId: "sid-9b" }));
209
227
  expect(result.result).toBe("");
210
228
  });
211
229
 
@@ -213,7 +231,7 @@ describe("Claude", () => {
213
231
  mockSpawn({ stdout: "not json at all", exitCode: 0 });
214
232
  const claude = makeClaude();
215
233
  try {
216
- await runSync(claude, opts({ sessionFlag: "--resume", sessionId: "sid-10" }));
234
+ await runSync(claude, opts({ resume: true, sessionId: "sid-10" }));
217
235
  expect.unreachable("should have thrown");
218
236
  } catch (err) {
219
237
  expect(err).toBeInstanceOf(ClaudeParseError);
@@ -224,7 +242,7 @@ describe("Claude", () => {
224
242
  it("returns duration and cost from envelope", async () => {
225
243
  mockSpawn({ stdout: jsonResult({ action: "send" }), exitCode: 0 });
226
244
  const claude = makeClaude();
227
- const result = await runSync(claude, opts({ sessionFlag: "--resume", sessionId: "sid-11" }));
245
+ const result = await runSync(claude, opts({ resume: true, sessionId: "sid-11" }));
228
246
  expect(result.duration).toBe("1.2s");
229
247
  expect(result.cost).toBe("$0.0500");
230
248
  });
@@ -232,7 +250,7 @@ describe("Claude", () => {
232
250
  it("does not set timeout when timeoutMs is not provided", async () => {
233
251
  mockSpawn({ stdout: jsonResult({ action: "send" }), exitCode: 0 });
234
252
  const claude = makeClaude();
235
- const result = await runSync(claude, opts({ sessionFlag: "--resume", sessionId: "sid-12" }));
253
+ const result = await runSync(claude, opts({ resume: true, sessionId: "sid-12" }));
236
254
  expect(result.structuredOutput).toEqual({ action: "send" });
237
255
  });
238
256
 
package/src/claude.ts CHANGED
@@ -4,12 +4,13 @@ const log = createLogger("claude");
4
4
 
5
5
  export interface ClaudeRunOptions {
6
6
  prompt: string;
7
- sessionFlag: "--resume" | "--session-id";
7
+ resume?: boolean;
8
8
  sessionId: string;
9
9
  forkSession?: boolean;
10
10
  model?: string;
11
11
  systemPrompt?: string;
12
12
  timeoutMs?: number;
13
+ plainText?: boolean;
13
14
  }
14
15
 
15
16
  export interface ClaudeResult {
@@ -89,7 +90,9 @@ export class Claude {
89
90
  const env = { ...process.env };
90
91
  delete env.CLAUDECODE;
91
92
 
92
- const args = ["claude", "-p", options.sessionFlag, options.sessionId, "--output-format", "json", "--json-schema", this.#jsonSchema];
93
+ const sessionFlag = options.resume ? "--resume" : "--session-id";
94
+ const args = ["claude", "-p", sessionFlag, options.sessionId, "--output-format", "json"];
95
+ if (!options.plainText) args.push("--json-schema", this.#jsonSchema);
93
96
  if (options.forkSession) args.push("--fork-session");
94
97
  if (options.model) args.push("--model", options.model);
95
98
  if (options.systemPrompt) args.push("--append-system-prompt", options.systemPrompt);
@@ -98,7 +101,7 @@ export class Claude {
98
101
  log.debug(
99
102
  {
100
103
  model: options.model,
101
- sessionFlag: options.sessionFlag,
104
+ resume: options.resume,
102
105
  sessionId: options.sessionId,
103
106
  promptLen: options.prompt.length,
104
107
  hasSystemPrompt: !!options.systemPrompt,
package/src/cli.ts CHANGED
@@ -3,6 +3,7 @@ import {existsSync, readFileSync} from "node:fs";
3
3
  import {join, resolve} from "node:path";
4
4
  import {createInterface} from "node:readline";
5
5
  import {defineCommand} from "citty";
6
+ import pkg from "../package.json" with { type: "json" };
6
7
  import {initLogger} from "./logger";
7
8
  import {ServiceManager, type SystemService} from "./service";
8
9
  import {loadSessions} from "./sessions";
@@ -180,7 +181,7 @@ const serviceCommand = defineCommand({
180
181
  });
181
182
 
182
183
  export const main = defineCommand({
183
- meta: { name: "macroclaw", description: "Telegram-to-Claude-Code bridge", version: "0.0.0-dev" },
184
+ meta: { name: pkg.name, description: pkg.description, version: pkg.version },
184
185
  subCommands: { start: startCommand, setup: setupCommand, claude: claudeCommand, service: serviceCommand },
185
186
  });
186
187
 
@@ -230,7 +230,7 @@ describe("Orchestrator", () => {
230
230
  });
231
231
 
232
232
  describe("session management", () => {
233
- it("uses --resume for existing session", async () => {
233
+ it("uses resume=true for existing session", async () => {
234
234
  saveSessions({ mainSessionId: "existing-session" }, tmpSettingsDir);
235
235
  const claude = mockClaude(successResult({ action: "send", message: "ok", actionReason: "ok" }));
236
236
  const { orch } = makeOrchestrator(claude);
@@ -238,7 +238,7 @@ describe("Orchestrator", () => {
238
238
  orch.handleMessage("hello");
239
239
  await waitForProcessing();
240
240
 
241
- expect(claude.run.mock.calls[0][0].sessionFlag).toBe("--resume");
241
+ expect(claude.run.mock.calls[0][0].resume).toBe(true);
242
242
  expect(claude.run.mock.calls[0][0].sessionId).toBe("existing-session");
243
243
  });
244
244
 
@@ -249,7 +249,7 @@ describe("Orchestrator", () => {
249
249
  orch.handleMessage("hello");
250
250
  await waitForProcessing();
251
251
 
252
- expect(claude.run.mock.calls[0][0].sessionFlag).toBe("--session-id");
252
+ expect(claude.run.mock.calls[0][0].resume).toBe(false);
253
253
  expect(claude.run.mock.calls[0][0].sessionId).toMatch(/^[0-9a-f]{8}-/);
254
254
  });
255
255
 
@@ -267,12 +267,12 @@ describe("Orchestrator", () => {
267
267
  await waitForProcessing();
268
268
 
269
269
  expect(callCount).toBe(2);
270
- expect(claude.run.mock.calls[0][0].sessionFlag).toBe("--resume");
271
- expect(claude.run.mock.calls[1][0].sessionFlag).toBe("--session-id");
270
+ expect(claude.run.mock.calls[0][0].resume).toBe(true);
271
+ expect(claude.run.mock.calls[1][0].resume).toBe(false);
272
272
  expect(claude.run.mock.calls[1][0].sessionId).not.toBe("old-session");
273
273
  });
274
274
 
275
- it("switches to --resume after first success", async () => {
275
+ it("switches to resume=true after first success", async () => {
276
276
  const claude = mockClaude(successResult({ action: "send", message: "ok", actionReason: "ok" }));
277
277
  const { orch } = makeOrchestrator(claude);
278
278
 
@@ -281,8 +281,8 @@ describe("Orchestrator", () => {
281
281
  orch.handleMessage("second");
282
282
  await waitForProcessing();
283
283
 
284
- expect(claude.run.mock.calls[0][0].sessionFlag).toBe("--session-id");
285
- expect(claude.run.mock.calls[1][0].sessionFlag).toBe("--resume");
284
+ expect(claude.run.mock.calls[0][0].resume).toBe(false);
285
+ expect(claude.run.mock.calls[1][0].resume).toBe(true);
286
286
  });
287
287
 
288
288
  it("handleSessionCommand sends session via onResponse", async () => {
@@ -314,7 +314,7 @@ describe("Orchestrator", () => {
314
314
  await waitForProcessing();
315
315
 
316
316
  // background-agent should use --resume and forkSession
317
- expect(claude.run.mock.calls[1][0].sessionFlag).toBe("--resume");
317
+ expect(claude.run.mock.calls[1][0].resume).toBe(true);
318
318
  expect(claude.run.mock.calls[1][0].forkSession).toBe(true);
319
319
  });
320
320
 
@@ -519,6 +519,92 @@ describe("Orchestrator", () => {
519
519
 
520
520
  expect(responses[0].message).toBe("No background agents running.");
521
521
  });
522
+
523
+ it("includes peek buttons and dismiss when agents are running", async () => {
524
+ const claude = mockClaude(() => new Promise<ClaudeResult>(() => {}));
525
+ const { orch, responses } = makeOrchestrator(claude);
526
+
527
+ orch.handleBackgroundCommand("long-task");
528
+ await waitForProcessing();
529
+
530
+ orch.handleBackgroundList();
531
+ await waitForProcessing();
532
+
533
+ const listResponse = responses[responses.length - 1];
534
+ expect(listResponse.message).toContain("long-task");
535
+ expect(listResponse.buttons).toBeDefined();
536
+ expect(listResponse.buttons!.length).toBe(2); // 1 peek + dismiss
537
+ const peekBtn = listResponse.buttons![0];
538
+ expect(typeof peekBtn).toBe("object");
539
+ expect((peekBtn as any).data).toMatch(/^peek:/);
540
+ expect((peekBtn as any).text).toContain("long-task");
541
+ expect(listResponse.buttons![1]).toBe("_dismiss");
542
+ });
543
+ });
544
+
545
+ describe("handlePeek", () => {
546
+ it("returns 'not found' for unknown sessionId", async () => {
547
+ const claude = mockClaude(successResult({ action: "send", message: "ok", actionReason: "ok" }));
548
+ const { orch, responses } = makeOrchestrator(claude);
549
+
550
+ await orch.handlePeek("nonexistent-session");
551
+ await waitForProcessing();
552
+
553
+ expect(responses[0].message).toBe("Agent not found or already finished.");
554
+ });
555
+
556
+ it("peeks at running agent and returns status", async () => {
557
+ let callCount = 0;
558
+ const claude = mockClaude(async (): Promise<ClaudeResult> => {
559
+ callCount++;
560
+ if (callCount === 1) return new Promise(() => {}); // bg agent never finishes
561
+ return { structuredOutput: null, sessionId: "peek-session", result: "Working on it, 50% done." };
562
+ });
563
+ const { orch, responses } = makeOrchestrator(claude);
564
+
565
+ orch.handleBackgroundCommand("research");
566
+ await waitForProcessing();
567
+
568
+ // Get the internal session ID from the peek button
569
+ orch.handleBackgroundList();
570
+ await waitForProcessing();
571
+ const listResponse = responses[responses.length - 1];
572
+ const peekBtn = listResponse.buttons![0] as { text: string; data: string };
573
+ const sessionId = peekBtn.data.slice(5); // strip "peek:"
574
+
575
+ await orch.handlePeek(sessionId);
576
+ await waitForProcessing();
577
+
578
+ const messages = responses.map((r) => r.message);
579
+ expect(messages.some((m) => m.includes("Peeking at"))).toBe(true);
580
+ expect(messages.some((m) => m.includes("Working on it"))).toBe(true);
581
+ });
582
+
583
+ it("handles Claude error during peek gracefully", async () => {
584
+ let callCount = 0;
585
+ const claude = mockClaude(async (): Promise<ClaudeResult> => {
586
+ callCount++;
587
+ if (callCount === 1) return new Promise(() => {}); // bg agent never finishes
588
+ throw new Error("connection lost");
589
+ });
590
+ const { orch, responses } = makeOrchestrator(claude);
591
+
592
+ orch.handleBackgroundCommand("failing-peek");
593
+ await waitForProcessing();
594
+
595
+ // Get the internal session ID from the peek button
596
+ orch.handleBackgroundList();
597
+ await waitForProcessing();
598
+ const listResponse = responses[responses.length - 1];
599
+ const peekBtn = listResponse.buttons![0] as { text: string; data: string };
600
+ const sessionId = peekBtn.data.slice(5);
601
+
602
+ await orch.handlePeek(sessionId);
603
+ await waitForProcessing();
604
+
605
+ const messages = responses.map((r) => r.message);
606
+ expect(messages.some((m) => m.includes("Couldn't peek at"))).toBe(true);
607
+ });
522
608
  });
523
609
 
524
610
  describe("handleBackgroundCommand", () => {
@@ -5,6 +5,7 @@ import { createLogger } from "./logger";
5
5
  import { BG_TIMEOUT, CRON_TIMEOUT, MAIN_TIMEOUT, SYSTEM_PROMPT } from "./prompts";
6
6
  import { Queue } from "./queue";
7
7
  import { loadSessions, newSessionId, type Sessions, saveSessions } from "./sessions";
8
+ import type { ButtonSpec } from "./telegram";
8
9
 
9
10
  const log = createLogger("orchestrator");
10
11
 
@@ -32,10 +33,12 @@ const jsonSchema = JSON.stringify(z.toJSONSchema(claudeResponseSchema, { target:
32
33
 
33
34
  // --- Public response type ---
34
35
 
36
+ export type { ButtonSpec };
37
+
35
38
  export interface OrchestratorResponse {
36
39
  message: string;
37
40
  files?: string[];
38
- buttons?: string[];
41
+ buttons?: ButtonSpec[];
39
42
  }
40
43
 
41
44
  // --- Internal request types ---
@@ -86,7 +89,7 @@ export class Orchestrator {
86
89
  #claude: Claude;
87
90
  #sessions: Sessions;
88
91
  #sessionId: string;
89
- #sessionFlag: "--resume" | "--session-id";
92
+ #resumeSession: boolean;
90
93
  #sessionResolved = false;
91
94
  #config: OrchestratorConfig;
92
95
  #active = new Map<string, BackgroundInfo>();
@@ -101,10 +104,10 @@ export class Orchestrator {
101
104
 
102
105
  if (this.#sessions.mainSessionId) {
103
106
  this.#sessionId = this.#sessions.mainSessionId;
104
- this.#sessionFlag = "--resume";
107
+ this.#resumeSession = true;
105
108
  } else {
106
109
  this.#sessionId = newSessionId();
107
- this.#sessionFlag = "--session-id";
110
+ this.#resumeSession = false;
108
111
  saveSessions({ mainSessionId: this.#sessionId }, config.settingsDir);
109
112
  log.info({ sessionId: this.#sessionId }, "Created new session");
110
113
  }
@@ -140,7 +143,39 @@ export class Orchestrator {
140
143
  const elapsed = Math.round((Date.now() - a.startTime.getTime()) / 1000);
141
144
  return `- ${escapeHtml(a.name)} (${elapsed}s)`;
142
145
  });
143
- this.#callOnResponse({ message: lines.join("\n") });
146
+ const buttons: ButtonSpec[] = agents.map((a) => {
147
+ const elapsed = Math.round((Date.now() - a.startTime.getTime()) / 1000);
148
+ const text = `${a.name} (${elapsed}s)`.slice(0, 27);
149
+ return { text, data: `peek:${a.sessionId}` };
150
+ });
151
+ buttons.push("_dismiss");
152
+ this.#callOnResponse({ message: lines.join("\n"), buttons });
153
+ }
154
+
155
+ async handlePeek(sessionId: string): Promise<void> {
156
+ const agent = this.#active.get(sessionId);
157
+ if (!agent) {
158
+ this.#callOnResponse({ message: "Agent not found or already finished." });
159
+ return;
160
+ }
161
+
162
+ this.#callOnResponse({ message: `Peeking at <b>${escapeHtml(agent.name)}</b>...` });
163
+
164
+ try {
165
+ const result = await this.#claude.run({
166
+ resume: true,
167
+ sessionId,
168
+ forkSession: true,
169
+ model: "haiku",
170
+ plainText: true,
171
+ prompt: "Give a brief status update: what has been done so far, what's currently happening, and what's remaining. 2-3 sentences max.",
172
+ });
173
+
174
+ const text = isDeferred(result) ? "Agent is still working..." : (result.result ?? "[No output]");
175
+ this.#callOnResponse({ message: `<b>[${escapeHtml(agent.name)}]</b> ${text}` });
176
+ } catch (err) {
177
+ this.#callOnResponse({ message: `Couldn't peek at ${escapeHtml(agent.name)}: ${err}` });
178
+ }
144
179
  }
145
180
 
146
181
  handleSessionCommand(): void {
@@ -265,15 +300,15 @@ export class Orchestrator {
265
300
  await logPrompt(request);
266
301
 
267
302
  if (built.useMainSession) {
268
- let result = await this.#callClaude(built, this.#sessionFlag, this.#sessionId, options?.forkSession);
303
+ let result = await this.#callClaude(built, this.#resumeSession, this.#sessionId, options?.forkSession);
269
304
 
270
305
  // Session resolution: if resume failed on first call, create new session
271
- if (!isDeferred(result) && !this.#sessionResolved && this.#sessionFlag === "--resume" && result.response.actionReason === "process-error") {
306
+ if (!isDeferred(result) && !this.#sessionResolved && this.#resumeSession && result.response.actionReason === "process-error") {
272
307
  this.#sessionId = newSessionId();
273
308
  log.info({ sessionId: this.#sessionId }, "Resume failed, created new session");
274
- this.#sessionFlag = "--session-id";
309
+ this.#resumeSession = false;
275
310
  saveSessions({ mainSessionId: this.#sessionId }, this.#config.settingsDir);
276
- result = await this.#callClaude(built, this.#sessionFlag, this.#sessionId);
311
+ result = await this.#callClaude(built, this.#resumeSession, this.#sessionId);
277
312
  }
278
313
 
279
314
  if (isDeferred(result)) return result;
@@ -288,7 +323,7 @@ export class Orchestrator {
288
323
  // Mark resolved on first success
289
324
  if (!this.#sessionResolved && result.response.actionReason !== "process-error") {
290
325
  this.#sessionResolved = true;
291
- this.#sessionFlag = "--resume";
326
+ this.#resumeSession = true;
292
327
  }
293
328
 
294
329
  await logResult(result.response);
@@ -297,7 +332,7 @@ export class Orchestrator {
297
332
 
298
333
  // background-agent: fork from main session for full context
299
334
  log.debug({ name: (request as { name: string }).name }, "Processing background-agent (forked session)");
300
- const bgResult = await this.#callClaude(built, "--resume", this.#sessionId, true);
335
+ const bgResult = await this.#callClaude(built, true, this.#sessionId, true);
301
336
  if (isDeferred(bgResult)) return bgResult;
302
337
  await logResult(bgResult.response);
303
338
  return bgResult.response;
@@ -368,11 +403,11 @@ export class Orchestrator {
368
403
  return { response: { action: "send", message: msg, actionReason: "no-structured-output" }, sessionId: result.sessionId };
369
404
  }
370
405
 
371
- async #callClaude(built: BuiltRequest, flag: "--resume" | "--session-id", sid: string, forkSession?: boolean): Promise<CallResult | ClaudeDeferredResult> {
406
+ async #callClaude(built: BuiltRequest, resume: boolean, sid: string, forkSession?: boolean): Promise<CallResult | ClaudeDeferredResult> {
372
407
  try {
373
408
  const result = await this.#claude.run({
374
409
  prompt: built.prompt,
375
- sessionFlag: flag,
410
+ resume,
376
411
  sessionId: sid,
377
412
  forkSession,
378
413
  model: built.model,
@@ -136,6 +136,20 @@ describe("buildInlineKeyboard", () => {
136
136
  expect(btn.callback_data).toBe("A");
137
137
  expect((kb.inline_keyboard[2][0] as any).text).toBe("C");
138
138
  });
139
+
140
+ it("supports object buttons with separate text and data", () => {
141
+ const kb = buildInlineKeyboard([
142
+ { text: "Peek agent-1 (30s)", data: "peek:session-123" },
143
+ "_dismiss",
144
+ ]);
145
+ expect(kb.inline_keyboard.length).toBe(2);
146
+ const peekBtn = kb.inline_keyboard[0][0] as any;
147
+ expect(peekBtn.text).toBe("Peek agent-1 (30s)");
148
+ expect(peekBtn.callback_data).toBe("peek:session-123");
149
+ const dismissBtn = kb.inline_keyboard[1][0] as any;
150
+ expect(dismissBtn.text).toBe("_dismiss");
151
+ expect(dismissBtn.callback_data).toBe("_dismiss");
152
+ });
139
153
  });
140
154
 
141
155
  describe("downloadFile", () => {
package/src/telegram.ts CHANGED
@@ -5,6 +5,8 @@ import { join } from "node:path";
5
5
  import { Bot, InlineKeyboard, InputFile } from "grammy";
6
6
  import { createLogger } from "./logger";
7
7
 
8
+ export type ButtonSpec = string | { text: string; data: string };
9
+
8
10
  const log = createLogger("telegram");
9
11
 
10
12
  const MAX_LENGTH = 4096;
@@ -15,11 +17,16 @@ export function createBot(token: string) {
15
17
  return new Bot(token);
16
18
  }
17
19
 
18
- export function buildInlineKeyboard(buttons: string[]): InlineKeyboard {
20
+ export function buildInlineKeyboard(buttons: ButtonSpec[]): InlineKeyboard {
19
21
  const kb = new InlineKeyboard();
20
22
  for (let i = 0; i < buttons.length; i++) {
21
23
  if (i > 0) kb.row();
22
- kb.text(buttons[i], buttons[i]);
24
+ const b = buttons[i];
25
+ if (typeof b === "string") {
26
+ kb.text(b, b);
27
+ } else {
28
+ kb.text(b.text, b.data);
29
+ }
23
30
  }
24
31
  return kb;
25
32
  }
@@ -28,7 +35,7 @@ export async function sendResponse(
28
35
  bot: Bot,
29
36
  chatId: string,
30
37
  text: string,
31
- buttons?: string[],
38
+ buttons?: ButtonSpec[],
32
39
  ): Promise<void> {
33
40
  const opts = { parse_mode: "HTML" as const };
34
41
  const replyMarkup = buttons?.length ? buildInlineKeyboard(buttons) : undefined;