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 +46 -46
- package/package.json +1 -1
- package/src/app.test.ts +45 -0
- package/src/app.ts +19 -5
- package/src/claude.integration-test.ts +4 -4
- package/src/claude.test.ts +30 -12
- package/src/claude.ts +6 -3
- package/src/cli.ts +2 -1
- package/src/orchestrator.test.ts +95 -9
- package/src/orchestrator.ts +48 -13
- package/src/telegram.test.ts +14 -0
- package/src/telegram.ts +10 -3
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
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
|
|
144
|
-
if (
|
|
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
|
-
|
|
148
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.`,
|
package/src/claude.test.ts
CHANGED
|
@@ -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
|
|
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
|
|
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({
|
|
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({
|
|
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({
|
|
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({
|
|
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({
|
|
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({
|
|
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({
|
|
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({
|
|
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({
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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:
|
|
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
|
|
package/src/orchestrator.test.ts
CHANGED
|
@@ -230,7 +230,7 @@ describe("Orchestrator", () => {
|
|
|
230
230
|
});
|
|
231
231
|
|
|
232
232
|
describe("session management", () => {
|
|
233
|
-
it("uses
|
|
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].
|
|
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].
|
|
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].
|
|
271
|
-
expect(claude.run.mock.calls[1][0].
|
|
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
|
|
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].
|
|
285
|
-
expect(claude.run.mock.calls[1][0].
|
|
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].
|
|
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", () => {
|
package/src/orchestrator.ts
CHANGED
|
@@ -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?:
|
|
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
|
-
#
|
|
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.#
|
|
107
|
+
this.#resumeSession = true;
|
|
105
108
|
} else {
|
|
106
109
|
this.#sessionId = newSessionId();
|
|
107
|
-
this.#
|
|
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
|
-
|
|
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.#
|
|
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.#
|
|
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.#
|
|
309
|
+
this.#resumeSession = false;
|
|
275
310
|
saveSessions({ mainSessionId: this.#sessionId }, this.#config.settingsDir);
|
|
276
|
-
result = await this.#callClaude(built, this.#
|
|
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.#
|
|
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,
|
|
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,
|
|
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
|
-
|
|
410
|
+
resume,
|
|
376
411
|
sessionId: sid,
|
|
377
412
|
forkSession,
|
|
378
413
|
model: built.model,
|
package/src/telegram.test.ts
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
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?:
|
|
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;
|