macroclaw 0.45.0 → 0.47.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 -13
- package/package.json +1 -1
- package/src/app.test.ts +388 -5
- package/src/app.ts +216 -45
- package/src/authorized-chats.test.ts +196 -0
- package/src/authorized-chats.ts +122 -0
- package/src/claude.test.ts +1 -1
- package/src/claude.ts +1 -1
- package/src/cli.test.ts +7 -7
- package/src/cli.ts +3 -3
- package/src/index.ts +1 -1
- package/src/orchestrator.test.ts +25 -25
- package/src/orchestrator.ts +10 -6
- package/src/prompt-builder.test.ts +28 -3
- package/src/prompt-builder.ts +20 -11
- package/src/scheduler.test.ts +36 -10
- package/src/scheduler.ts +7 -6
- package/src/sessions.test.ts +85 -19
- package/src/sessions.ts +33 -5
- package/src/settings.test.ts +40 -16
- package/src/settings.ts +29 -5
- package/src/setup.test.ts +12 -6
- package/src/setup.ts +8 -7
- package/workspace-template/.claude/skills/{schedule → schedule-event}/SKILL.md +29 -8
- package/workspace-template/CLAUDE.md +3 -1
- package/workspace-template/data/schedule.json +2 -0
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join, resolve } from "node:path";
|
|
3
|
+
import { z } from "zod/v4";
|
|
4
|
+
import { createLogger } from "./logger";
|
|
5
|
+
|
|
6
|
+
const log = createLogger("authorized-chats");
|
|
7
|
+
|
|
8
|
+
export const ADMIN_CHAT_NAME = "admin";
|
|
9
|
+
const NAME_PATTERN = /^[a-z0-9-]+$/;
|
|
10
|
+
|
|
11
|
+
const chatSchema = z.object({
|
|
12
|
+
chatId: z.string().regex(/^-?\d+$/, "Must be a numeric Telegram chat ID"),
|
|
13
|
+
name: z.string().regex(NAME_PATTERN, "Name must be lowercase alphanumeric or dashes"),
|
|
14
|
+
addedAt: z.string(),
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
const fileSchema = z.object({
|
|
18
|
+
chats: z.array(chatSchema).default([]),
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
export type AuthorizedChat = z.infer<typeof chatSchema>;
|
|
22
|
+
export type AuthorizedChatsFile = z.infer<typeof fileSchema>;
|
|
23
|
+
|
|
24
|
+
const defaultDir = resolve(process.env.HOME || "~", ".macroclaw");
|
|
25
|
+
|
|
26
|
+
export class DuplicateChatError extends Error {
|
|
27
|
+
constructor(public readonly kind: "name" | "chatId", public readonly value: string) {
|
|
28
|
+
super(`Chat with ${kind} "${value}" is already authorized`);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export class InvalidChatNameError extends Error {
|
|
33
|
+
constructor(public readonly chatName: string, reason: string) {
|
|
34
|
+
super(`Invalid chat name "${chatName}": ${reason}`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export class UnknownChatError extends Error {
|
|
39
|
+
constructor(public readonly chatName: string) {
|
|
40
|
+
super(`No authorized chat named "${chatName}"`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export class AuthorizedChats {
|
|
45
|
+
readonly #dir: string;
|
|
46
|
+
#chats: AuthorizedChat[] = [];
|
|
47
|
+
|
|
48
|
+
constructor(dir: string = defaultDir) {
|
|
49
|
+
this.#dir = dir;
|
|
50
|
+
this.#chats = this.#loadFromDisk();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
list(): readonly AuthorizedChat[] {
|
|
54
|
+
return this.#chats;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
byName(name: string): AuthorizedChat | undefined {
|
|
58
|
+
return this.#chats.find((c) => c.name === name);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
byChatId(chatId: string): AuthorizedChat | undefined {
|
|
62
|
+
return this.#chats.find((c) => c.chatId === chatId);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
add(chatId: string, name: string, now: Date = new Date()): AuthorizedChat {
|
|
66
|
+
AuthorizedChats.validateName(name);
|
|
67
|
+
|
|
68
|
+
if (this.byName(name)) throw new DuplicateChatError("name", name);
|
|
69
|
+
if (this.byChatId(chatId)) throw new DuplicateChatError("chatId", chatId);
|
|
70
|
+
|
|
71
|
+
const chat = chatSchema.parse({ chatId, name, addedAt: now.toISOString() });
|
|
72
|
+
this.#chats.push(chat);
|
|
73
|
+
this.#persist();
|
|
74
|
+
return chat;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
remove(name: string): AuthorizedChat {
|
|
78
|
+
const existing = this.byName(name);
|
|
79
|
+
if (!existing) throw new UnknownChatError(name);
|
|
80
|
+
this.#chats = this.#chats.filter((c) => c.name !== name);
|
|
81
|
+
this.#persist();
|
|
82
|
+
return existing;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
static validateName(name: string): void {
|
|
86
|
+
if (name === ADMIN_CHAT_NAME) {
|
|
87
|
+
throw new InvalidChatNameError(name, `"${ADMIN_CHAT_NAME}" is reserved`);
|
|
88
|
+
}
|
|
89
|
+
if (!NAME_PATTERN.test(name)) {
|
|
90
|
+
throw new InvalidChatNameError(name, "must be lowercase alphanumeric or dashes");
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
#loadFromDisk(): AuthorizedChat[] {
|
|
95
|
+
const path = this.#path();
|
|
96
|
+
if (!existsSync(path)) return [];
|
|
97
|
+
try {
|
|
98
|
+
const raw = JSON.parse(readFileSync(path, "utf-8"));
|
|
99
|
+
const result = fileSchema.safeParse(raw);
|
|
100
|
+
if (!result.success) {
|
|
101
|
+
log.warn(
|
|
102
|
+
{ issues: result.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`) },
|
|
103
|
+
"authorized-chats.json validation failed; starting with empty list",
|
|
104
|
+
);
|
|
105
|
+
return [];
|
|
106
|
+
}
|
|
107
|
+
return result.data.chats;
|
|
108
|
+
} catch (err) {
|
|
109
|
+
log.warn({ err }, "Failed to load authorized-chats.json; starting with empty list");
|
|
110
|
+
return [];
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
#persist(): void {
|
|
115
|
+
mkdirSync(this.#dir, { recursive: true });
|
|
116
|
+
writeFileSync(this.#path(), `${JSON.stringify({ chats: this.#chats }, null, 2)}\n`);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
#path(): string {
|
|
120
|
+
return join(this.#dir, "authorized-chats.json");
|
|
121
|
+
}
|
|
122
|
+
}
|
package/src/claude.test.ts
CHANGED
|
@@ -479,7 +479,7 @@ describe("Claude factory", () => {
|
|
|
479
479
|
claude.newSession(textResult);
|
|
480
480
|
const args = spawnArgs();
|
|
481
481
|
expect(args).toContain("--disallowedTools");
|
|
482
|
-
expect(args).toContain("CronList,CronDelete,CronCreate,AskUserQuestion");
|
|
482
|
+
expect(args).toContain("CronList,CronDelete,CronCreate,AskUserQuestion,RemoteTrigger");
|
|
483
483
|
});
|
|
484
484
|
|
|
485
485
|
it("spawns with stdin: pipe, stdout: pipe, stderr: pipe", () => {
|
package/src/claude.ts
CHANGED
|
@@ -257,7 +257,7 @@ export class Claude {
|
|
|
257
257
|
"--input-format", "stream-json",
|
|
258
258
|
"--output-format", "stream-json",
|
|
259
259
|
"--verbose",
|
|
260
|
-
"--disallowedTools", "CronList,CronDelete,CronCreate,AskUserQuestion",
|
|
260
|
+
"--disallowedTools", "CronList,CronDelete,CronCreate,AskUserQuestion,RemoteTrigger",
|
|
261
261
|
];
|
|
262
262
|
|
|
263
263
|
if (mode.kind === "resume") {
|
package/src/cli.test.ts
CHANGED
|
@@ -20,7 +20,7 @@ const { main } = await import("./cli");
|
|
|
20
20
|
|
|
21
21
|
function createMockWizard(overrides?: { collectSettings?: (defaults?: Record<string, unknown>) => Promise<unknown>; installService?: () => Promise<void>; forceInstallService?: () => Promise<void> }) {
|
|
22
22
|
return {
|
|
23
|
-
collectSettings: overrides?.collectSettings ?? mock(async () => ({ botToken: "tok",
|
|
23
|
+
collectSettings: overrides?.collectSettings ?? mock(async () => ({ botToken: "tok", adminChatId: "123" })),
|
|
24
24
|
installService: overrides?.installService ?? mock(async () => {}),
|
|
25
25
|
forceInstallService: overrides?.forceInstallService ?? mock(async () => {}),
|
|
26
26
|
} as unknown as SetupWizard;
|
|
@@ -28,7 +28,7 @@ function createMockWizard(overrides?: { collectSettings?: (defaults?: Record<str
|
|
|
28
28
|
|
|
29
29
|
function createMockSettings(overrides?: Partial<SettingsManager>) {
|
|
30
30
|
return {
|
|
31
|
-
load: mock(() => ({ botToken: "tok",
|
|
31
|
+
load: mock(() => ({ botToken: "tok", adminChatId: "123", model: "sonnet", workspace: "/tmp" })),
|
|
32
32
|
loadRaw: mock(() => null),
|
|
33
33
|
save: mock(() => {}),
|
|
34
34
|
applyEnvOverrides: mock((s: unknown) => ({ settings: s, overrides: new Set() })),
|
|
@@ -72,14 +72,14 @@ describe("Cli.setup", () => {
|
|
|
72
72
|
const settings = createMockSettings();
|
|
73
73
|
const cli = new Cli({ wizard, settings });
|
|
74
74
|
await cli.setup();
|
|
75
|
-
expect((settings.save as ReturnType<typeof mock>)).toHaveBeenCalledWith({ botToken: "tok",
|
|
75
|
+
expect((settings.save as ReturnType<typeof mock>)).toHaveBeenCalledWith({ botToken: "tok", adminChatId: "123" });
|
|
76
76
|
});
|
|
77
77
|
|
|
78
78
|
it("passes existing settings as defaults to wizard", async () => {
|
|
79
|
-
const existing = { botToken: "old-tok",
|
|
79
|
+
const existing = { botToken: "old-tok", adminChatId: "999" };
|
|
80
80
|
let receivedDefaults: unknown = null;
|
|
81
81
|
const wizard = createMockWizard({
|
|
82
|
-
collectSettings: async (defaults) => { receivedDefaults = defaults; return { botToken: "tok",
|
|
82
|
+
collectSettings: async (defaults) => { receivedDefaults = defaults; return { botToken: "tok", adminChatId: "123" }; },
|
|
83
83
|
});
|
|
84
84
|
const settings = createMockSettings({ loadRaw: () => existing } as unknown as Partial<SettingsManager>);
|
|
85
85
|
const cli = new Cli({ wizard, settings });
|
|
@@ -236,7 +236,7 @@ describe("Cli.claude", () => {
|
|
|
236
236
|
const fs = await import("node:fs");
|
|
237
237
|
const dir = `/tmp/macroclaw-test-claude-${Date.now()}`;
|
|
238
238
|
fs.mkdirSync(dir, { recursive: true });
|
|
239
|
-
fs.writeFileSync(`${dir}/settings.json`, JSON.stringify({ botToken: "tok",
|
|
239
|
+
fs.writeFileSync(`${dir}/settings.json`, JSON.stringify({ botToken: "tok", adminChatId: "123", model: "opus", workspace: "/tmp" }));
|
|
240
240
|
fs.writeFileSync(`${dir}/sessions.json`, JSON.stringify({ mainSessionId: "sess-123" }));
|
|
241
241
|
|
|
242
242
|
mockExecSync.mockClear();
|
|
@@ -257,7 +257,7 @@ describe("Cli.claude", () => {
|
|
|
257
257
|
const fs = await import("node:fs");
|
|
258
258
|
const dir = `/tmp/macroclaw-test-claude-${Date.now()}`;
|
|
259
259
|
fs.mkdirSync(dir, { recursive: true });
|
|
260
|
-
fs.writeFileSync(`${dir}/settings.json`, JSON.stringify({ botToken: "tok",
|
|
260
|
+
fs.writeFileSync(`${dir}/settings.json`, JSON.stringify({ botToken: "tok", adminChatId: "123", model: "sonnet", workspace: "/tmp" }));
|
|
261
261
|
fs.writeFileSync(`${dir}/sessions.json`, JSON.stringify({}));
|
|
262
262
|
|
|
263
263
|
mockExecSync.mockClear();
|
package/src/cli.ts
CHANGED
|
@@ -2,7 +2,7 @@ import {execSync} from "node:child_process";
|
|
|
2
2
|
import {createInterface} from "node:readline";
|
|
3
3
|
import {defineCommand} from "citty";
|
|
4
4
|
import pkg from "../package.json" with {type: "json"};
|
|
5
|
-
import {
|
|
5
|
+
import {getMainSession} from "./sessions";
|
|
6
6
|
import {SettingsManager} from "./settings";
|
|
7
7
|
import {type SetupIo, SetupWizard} from "./setup";
|
|
8
8
|
import {SystemServiceManager} from "./system-service";
|
|
@@ -32,9 +32,9 @@ export class Cli {
|
|
|
32
32
|
|
|
33
33
|
claude(): void {
|
|
34
34
|
const settings = this.#settingsManager.load();
|
|
35
|
-
const
|
|
35
|
+
const adminSession = getMainSession("admin", this.#settingsManager.dir);
|
|
36
36
|
const args = ["claude"];
|
|
37
|
-
if (
|
|
37
|
+
if (adminSession) args.push("--resume", adminSession);
|
|
38
38
|
args.push("--model", settings.model);
|
|
39
39
|
execSync(args.join(" "), { cwd: settings.workspace, stdio: "inherit", env: { ...process.env, CLAUDECODE: "" } });
|
|
40
40
|
}
|
package/src/index.ts
CHANGED
|
@@ -37,7 +37,7 @@ export async function start(): Promise<void> {
|
|
|
37
37
|
|
|
38
38
|
const config: AppConfig = {
|
|
39
39
|
botToken: resolved.botToken,
|
|
40
|
-
|
|
40
|
+
adminChatId: resolved.adminChatId,
|
|
41
41
|
workspace,
|
|
42
42
|
model: resolved.model,
|
|
43
43
|
timeZone: resolved.timeZone,
|
package/src/orchestrator.test.ts
CHANGED
|
@@ -263,7 +263,7 @@ describe("Orchestrator", () => {
|
|
|
263
263
|
});
|
|
264
264
|
|
|
265
265
|
it("reports error when resume fails", async () => {
|
|
266
|
-
saveSessions({
|
|
266
|
+
saveSessions({ mainSessions: { admin: "old-session" } }, tmpSettingsDir);
|
|
267
267
|
const claude = mockClaude((): ClaudeProcess<unknown> => {
|
|
268
268
|
const proc = autoProcess(null, "old-session");
|
|
269
269
|
(proc as any).send = mock(async () => { throw new QueryProcessError(1, "session not found"); });
|
|
@@ -282,7 +282,7 @@ describe("Orchestrator", () => {
|
|
|
282
282
|
|
|
283
283
|
describe("session management", () => {
|
|
284
284
|
it("uses resumeSession for existing session", async () => {
|
|
285
|
-
saveSessions({
|
|
285
|
+
saveSessions({ mainSessions: { admin: "existing-session" } }, tmpSettingsDir);
|
|
286
286
|
const claude = mockClaude({ action: "send", message: "ok", actionReason: "ok" });
|
|
287
287
|
const { orch } = makeOrchestrator(claude);
|
|
288
288
|
|
|
@@ -320,7 +320,7 @@ describe("Orchestrator", () => {
|
|
|
320
320
|
});
|
|
321
321
|
|
|
322
322
|
it("background-agent forks from main session", async () => {
|
|
323
|
-
saveSessions({
|
|
323
|
+
saveSessions({ mainSessions: { admin: "main-session" } }, tmpSettingsDir);
|
|
324
324
|
const claude = mockClaude({ action: "send", message: "ok", actionReason: "ok" });
|
|
325
325
|
const { orch } = makeOrchestrator(claude);
|
|
326
326
|
|
|
@@ -449,7 +449,7 @@ describe("Orchestrator", () => {
|
|
|
449
449
|
});
|
|
450
450
|
|
|
451
451
|
it("delivers result with error when session not in runningSessions", async () => {
|
|
452
|
-
saveSessions({
|
|
452
|
+
saveSessions({ mainSessions: { admin: "main-session" } }, tmpSettingsDir);
|
|
453
453
|
const { process: mainProc, resolve } = pendingProcess("main-session");
|
|
454
454
|
const claude = mockClaude(() => mainProc);
|
|
455
455
|
const { orch, responses } = makeOrchestrator(claude);
|
|
@@ -539,7 +539,7 @@ describe("Orchestrator", () => {
|
|
|
539
539
|
|
|
540
540
|
describe("cron routing", () => {
|
|
541
541
|
it("cron always forks as background, never goes through queue", async () => {
|
|
542
|
-
saveSessions({
|
|
542
|
+
saveSessions({ mainSessions: { admin: "main-session" } }, tmpSettingsDir);
|
|
543
543
|
const claude = mockClaude({ action: "send", message: "ok", actionReason: "ok" });
|
|
544
544
|
const { orch } = makeOrchestrator(claude);
|
|
545
545
|
|
|
@@ -553,7 +553,7 @@ describe("Orchestrator", () => {
|
|
|
553
553
|
});
|
|
554
554
|
|
|
555
555
|
it("cron uses config model when none specified", async () => {
|
|
556
|
-
saveSessions({
|
|
556
|
+
saveSessions({ mainSessions: { admin: "main-session" } }, tmpSettingsDir);
|
|
557
557
|
const claude = mockClaude({ action: "send", message: "ok", actionReason: "ok" });
|
|
558
558
|
const { orch } = makeOrchestrator(claude, { model: "sonnet" });
|
|
559
559
|
|
|
@@ -564,7 +564,7 @@ describe("Orchestrator", () => {
|
|
|
564
564
|
});
|
|
565
565
|
|
|
566
566
|
it("cron result feeds back into main session", async () => {
|
|
567
|
-
saveSessions({
|
|
567
|
+
saveSessions({ mainSessions: { admin: "main-session" } }, tmpSettingsDir);
|
|
568
568
|
let callCount = 0;
|
|
569
569
|
const claude = mockClaude((): ClaudeProcess<unknown> => {
|
|
570
570
|
callCount++;
|
|
@@ -591,7 +591,7 @@ describe("Orchestrator", () => {
|
|
|
591
591
|
});
|
|
592
592
|
|
|
593
593
|
it("includes detail buttons and dismiss when sessions are running", async () => {
|
|
594
|
-
saveSessions({
|
|
594
|
+
saveSessions({ mainSessions: { admin: "test-session" } }, tmpSettingsDir);
|
|
595
595
|
const claude = mockClaude((): ClaudeProcess<unknown> => {
|
|
596
596
|
const { process } = pendingProcess(`bg-${Date.now()}`);
|
|
597
597
|
return process;
|
|
@@ -644,7 +644,7 @@ describe("Orchestrator", () => {
|
|
|
644
644
|
});
|
|
645
645
|
|
|
646
646
|
it("peeks at running agent and returns status", async () => {
|
|
647
|
-
saveSessions({
|
|
647
|
+
saveSessions({ mainSessions: { admin: "test-session" } }, tmpSettingsDir);
|
|
648
648
|
let callCount = 0;
|
|
649
649
|
const claude = mockClaude((): ClaudeProcess<unknown> => {
|
|
650
650
|
callCount++;
|
|
@@ -674,7 +674,7 @@ describe("Orchestrator", () => {
|
|
|
674
674
|
});
|
|
675
675
|
|
|
676
676
|
it("handles Claude error during peek gracefully", async () => {
|
|
677
|
-
saveSessions({
|
|
677
|
+
saveSessions({ mainSessions: { admin: "test-session" } }, tmpSettingsDir);
|
|
678
678
|
let callCount = 0;
|
|
679
679
|
const claude = mockClaude((): ClaudeProcess<unknown> => {
|
|
680
680
|
callCount++;
|
|
@@ -717,7 +717,7 @@ describe("Orchestrator", () => {
|
|
|
717
717
|
});
|
|
718
718
|
|
|
719
719
|
it("shows session details with peek/kill/dismiss buttons", async () => {
|
|
720
|
-
saveSessions({
|
|
720
|
+
saveSessions({ mainSessions: { admin: "test-session" } }, tmpSettingsDir);
|
|
721
721
|
const claude = mockClaude((): ClaudeProcess<unknown> => {
|
|
722
722
|
const { process } = pendingProcess("bg-sid");
|
|
723
723
|
return process;
|
|
@@ -748,7 +748,7 @@ describe("Orchestrator", () => {
|
|
|
748
748
|
});
|
|
749
749
|
|
|
750
750
|
it("truncates prompt at 300 chars", async () => {
|
|
751
|
-
saveSessions({
|
|
751
|
+
saveSessions({ mainSessions: { admin: "test-session" } }, tmpSettingsDir);
|
|
752
752
|
const longPrompt = "a".repeat(500);
|
|
753
753
|
const claude = mockClaude((): ClaudeProcess<unknown> => {
|
|
754
754
|
const { process } = pendingProcess("bg-sid");
|
|
@@ -775,7 +775,7 @@ describe("Orchestrator", () => {
|
|
|
775
775
|
});
|
|
776
776
|
|
|
777
777
|
it("shows model when specified", async () => {
|
|
778
|
-
saveSessions({
|
|
778
|
+
saveSessions({ mainSessions: { admin: "test-session" } }, tmpSettingsDir);
|
|
779
779
|
const claude = mockClaude((): ClaudeProcess<unknown> => {
|
|
780
780
|
const { process } = pendingProcess("bg-sid");
|
|
781
781
|
return process;
|
|
@@ -834,7 +834,7 @@ describe("Orchestrator", () => {
|
|
|
834
834
|
});
|
|
835
835
|
|
|
836
836
|
it("kills running session and sends confirmation", async () => {
|
|
837
|
-
saveSessions({
|
|
837
|
+
saveSessions({ mainSessions: { admin: "test-session" } }, tmpSettingsDir);
|
|
838
838
|
const { process: bgProc } = pendingProcess("bg-sid");
|
|
839
839
|
const claude = mockClaude((): ClaudeProcess<unknown> => bgProc);
|
|
840
840
|
const { orch, responses } = makeOrchestrator(claude);
|
|
@@ -862,7 +862,7 @@ describe("Orchestrator", () => {
|
|
|
862
862
|
});
|
|
863
863
|
|
|
864
864
|
it("does not feed error back to queue after kill", async () => {
|
|
865
|
-
saveSessions({
|
|
865
|
+
saveSessions({ mainSessions: { admin: "test-session" } }, tmpSettingsDir);
|
|
866
866
|
const { process: bgProc, reject: rejectBg } = pendingProcess("bg-sid");
|
|
867
867
|
const claude = mockClaude((): ClaudeProcess<unknown> => bgProc);
|
|
868
868
|
const { orch, responses } = makeOrchestrator(claude);
|
|
@@ -890,7 +890,7 @@ describe("Orchestrator", () => {
|
|
|
890
890
|
|
|
891
891
|
describe("handleBackgroundCommand", () => {
|
|
892
892
|
it("spawns background agent and sends started message", async () => {
|
|
893
|
-
saveSessions({
|
|
893
|
+
saveSessions({ mainSessions: { admin: "test-session" } }, tmpSettingsDir);
|
|
894
894
|
const claude = mockClaude((): ClaudeProcess<unknown> => {
|
|
895
895
|
const { process } = pendingProcess("bg-sid");
|
|
896
896
|
return process;
|
|
@@ -907,7 +907,7 @@ describe("Orchestrator", () => {
|
|
|
907
907
|
|
|
908
908
|
describe("background management", () => {
|
|
909
909
|
it("spawns background agent and feeds result back to queue", async () => {
|
|
910
|
-
saveSessions({
|
|
910
|
+
saveSessions({ mainSessions: { admin: "test-session" } }, tmpSettingsDir);
|
|
911
911
|
const { process: bgProc, resolve: resolveBg } = pendingProcess("bg-sid");
|
|
912
912
|
|
|
913
913
|
let callCount = 0;
|
|
@@ -931,7 +931,7 @@ describe("Orchestrator", () => {
|
|
|
931
931
|
});
|
|
932
932
|
|
|
933
933
|
it("passes action and actionReason through result XML for silent background agent", async () => {
|
|
934
|
-
saveSessions({
|
|
934
|
+
saveSessions({ mainSessions: { admin: "test-session" } }, tmpSettingsDir);
|
|
935
935
|
const { process: bgProc, resolve: resolveBg } = pendingProcess("bg-sid");
|
|
936
936
|
|
|
937
937
|
let callCount = 0;
|
|
@@ -957,7 +957,7 @@ describe("Orchestrator", () => {
|
|
|
957
957
|
});
|
|
958
958
|
|
|
959
959
|
it("feeds error back to queue on spawn failure", async () => {
|
|
960
|
-
saveSessions({
|
|
960
|
+
saveSessions({ mainSessions: { admin: "test-session" } }, tmpSettingsDir);
|
|
961
961
|
const { process: bgProc, reject: rejectBg } = pendingProcess("bg-sid");
|
|
962
962
|
|
|
963
963
|
let callCount = 0;
|
|
@@ -981,7 +981,7 @@ describe("Orchestrator", () => {
|
|
|
981
981
|
|
|
982
982
|
describe("health checks", () => {
|
|
983
983
|
it("runs health check after interval and reports finished agent", async () => {
|
|
984
|
-
saveSessions({
|
|
984
|
+
saveSessions({ mainSessions: { admin: "test-session" } }, tmpSettingsDir);
|
|
985
985
|
const { process: bgProc } = pendingProcess("bg-sid");
|
|
986
986
|
|
|
987
987
|
let callCount = 0;
|
|
@@ -1013,7 +1013,7 @@ describe("Orchestrator", () => {
|
|
|
1013
1013
|
});
|
|
1014
1014
|
|
|
1015
1015
|
it("reports progress and schedules next check when not finished", async () => {
|
|
1016
|
-
saveSessions({
|
|
1016
|
+
saveSessions({ mainSessions: { admin: "test-session" } }, tmpSettingsDir);
|
|
1017
1017
|
const { process: bgProc } = pendingProcess("bg-sid");
|
|
1018
1018
|
|
|
1019
1019
|
let hcCount = 0;
|
|
@@ -1045,7 +1045,7 @@ describe("Orchestrator", () => {
|
|
|
1045
1045
|
});
|
|
1046
1046
|
|
|
1047
1047
|
it("kills unresponsive agent on health check timeout", async () => {
|
|
1048
|
-
saveSessions({
|
|
1048
|
+
saveSessions({ mainSessions: { admin: "test-session" } }, tmpSettingsDir);
|
|
1049
1049
|
const { process: bgProc } = pendingProcess("bg-sid");
|
|
1050
1050
|
|
|
1051
1051
|
const claude = mockClaude((info: CallInfo): ClaudeProcess<unknown> => {
|
|
@@ -1070,7 +1070,7 @@ describe("Orchestrator", () => {
|
|
|
1070
1070
|
});
|
|
1071
1071
|
|
|
1072
1072
|
it("does not run health checks when interval is 0", async () => {
|
|
1073
|
-
saveSessions({
|
|
1073
|
+
saveSessions({ mainSessions: { admin: "test-session" } }, tmpSettingsDir);
|
|
1074
1074
|
const { process: bgProc } = pendingProcess("bg-sid");
|
|
1075
1075
|
|
|
1076
1076
|
const claude = mockClaude((): ClaudeProcess<unknown> => bgProc);
|
|
@@ -1084,7 +1084,7 @@ describe("Orchestrator", () => {
|
|
|
1084
1084
|
});
|
|
1085
1085
|
|
|
1086
1086
|
it("clears health check timer when session is killed", async () => {
|
|
1087
|
-
saveSessions({
|
|
1087
|
+
saveSessions({ mainSessions: { admin: "test-session" } }, tmpSettingsDir);
|
|
1088
1088
|
const { process: bgProc } = pendingProcess("bg-sid");
|
|
1089
1089
|
|
|
1090
1090
|
let hcCount = 0;
|
|
@@ -1106,7 +1106,7 @@ describe("Orchestrator", () => {
|
|
|
1106
1106
|
});
|
|
1107
1107
|
|
|
1108
1108
|
it("stops health check if session completes organically before timer", async () => {
|
|
1109
|
-
saveSessions({
|
|
1109
|
+
saveSessions({ mainSessions: { admin: "test-session" } }, tmpSettingsDir);
|
|
1110
1110
|
const { process: bgProc, resolve: resolveBg } = pendingProcess("bg-sid");
|
|
1111
1111
|
|
|
1112
1112
|
let hcCount = 0;
|
package/src/orchestrator.ts
CHANGED
|
@@ -12,7 +12,7 @@ import { createLogger } from "./logger";
|
|
|
12
12
|
import { generateName } from "./naming";
|
|
13
13
|
import { PromptBuilder } from "./prompt-builder";
|
|
14
14
|
import { Queue } from "./queue";
|
|
15
|
-
import {
|
|
15
|
+
import { clearMainSession, getMainSession, setMainSession } from "./sessions";
|
|
16
16
|
|
|
17
17
|
type ButtonSpec = string | { text: string; data: string };
|
|
18
18
|
|
|
@@ -90,6 +90,8 @@ interface SessionInfo {
|
|
|
90
90
|
}
|
|
91
91
|
|
|
92
92
|
export interface OrchestratorConfig {
|
|
93
|
+
/** Name of the chat this orchestrator serves. Used to scope the sessions.json slot. Defaults to "admin". */
|
|
94
|
+
chatName?: string;
|
|
93
95
|
model: string;
|
|
94
96
|
workspace: string;
|
|
95
97
|
timeZone: string;
|
|
@@ -106,6 +108,7 @@ export interface OrchestratorConfig {
|
|
|
106
108
|
|
|
107
109
|
export class Orchestrator {
|
|
108
110
|
#config: Omit<OrchestratorConfig , 'claude'>;
|
|
111
|
+
#chatName: string;
|
|
109
112
|
#claude: Claude;
|
|
110
113
|
#prompts: PromptBuilder;
|
|
111
114
|
#waitThreshold: number;
|
|
@@ -119,7 +122,8 @@ export class Orchestrator {
|
|
|
119
122
|
|
|
120
123
|
constructor(config: OrchestratorConfig) {
|
|
121
124
|
this.#config = config;
|
|
122
|
-
this.#
|
|
125
|
+
this.#chatName = config.chatName ?? "admin";
|
|
126
|
+
this.#prompts = new PromptBuilder(config.timeZone, this.#chatName);
|
|
123
127
|
const envVars: Record<string, string> = { TZ: config.timeZone };
|
|
124
128
|
this.#claude = config.claude ?? new Claude({ workspace: config.workspace, systemPrompt: this.#prompts.systemPrompt, envVars });
|
|
125
129
|
this.#waitThreshold = config.waitThreshold ?? WAIT_THRESHOLD;
|
|
@@ -128,7 +132,7 @@ export class Orchestrator {
|
|
|
128
132
|
this.#queue = new Queue<OrchestratorRequest>();
|
|
129
133
|
this.#queue.setHandler((request) => this.#handleRequest(request));
|
|
130
134
|
|
|
131
|
-
this.#mainSessionId =
|
|
135
|
+
this.#mainSessionId = getMainSession(this.#chatName, config.settingsDir);
|
|
132
136
|
}
|
|
133
137
|
|
|
134
138
|
// --- Public handle methods ---
|
|
@@ -264,7 +268,7 @@ export class Orchestrator {
|
|
|
264
268
|
this.#mainProcess = null;
|
|
265
269
|
}
|
|
266
270
|
this.#mainSessionId = undefined;
|
|
267
|
-
|
|
271
|
+
clearMainSession(this.#chatName, this.#config.settingsDir);
|
|
268
272
|
log.info("Session cleared");
|
|
269
273
|
this.#callOnResponse({ message: "Session cleared." });
|
|
270
274
|
}
|
|
@@ -294,7 +298,7 @@ export class Orchestrator {
|
|
|
294
298
|
|
|
295
299
|
if (this.#mainProcess.sessionId !== this.#mainSessionId) {
|
|
296
300
|
this.#mainSessionId = this.#mainProcess.sessionId;
|
|
297
|
-
|
|
301
|
+
setMainSession(this.#chatName, this.#mainSessionId, this.#config.settingsDir);
|
|
298
302
|
}
|
|
299
303
|
|
|
300
304
|
log.info({ sessionId: this.#mainSessionId }, "Main process created");
|
|
@@ -340,7 +344,7 @@ export class Orchestrator {
|
|
|
340
344
|
{ model: this.#config.model },
|
|
341
345
|
);
|
|
342
346
|
this.#mainSessionId = this.#mainProcess.sessionId;
|
|
343
|
-
|
|
347
|
+
setMainSession(this.#chatName, this.#mainSessionId, this.#config.settingsDir);
|
|
344
348
|
}
|
|
345
349
|
|
|
346
350
|
await writeHistoryPrompt(request);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { describe, expect, it } from "bun:test";
|
|
2
2
|
import { PromptBuilder } from "./prompt-builder";
|
|
3
3
|
|
|
4
|
-
const p = new PromptBuilder("UTC");
|
|
4
|
+
const p = new PromptBuilder("UTC", "admin");
|
|
5
5
|
|
|
6
6
|
describe("systemPrompt", () => {
|
|
7
7
|
it("contains key sections", () => {
|
|
@@ -52,17 +52,22 @@ describe("systemPrompt", () => {
|
|
|
52
52
|
});
|
|
53
53
|
|
|
54
54
|
it("includes time zone but not a fixed date", () => {
|
|
55
|
-
const prague = new PromptBuilder("Europe/Prague");
|
|
55
|
+
const prague = new PromptBuilder("Europe/Prague", "admin");
|
|
56
56
|
expect(prague.systemPrompt).not.toContain("Current date:");
|
|
57
57
|
expect(prague.systemPrompt).toContain("Timezone: Europe/Prague");
|
|
58
58
|
expect(prague.systemPrompt).toContain("TZ env var is set");
|
|
59
59
|
});
|
|
60
|
+
|
|
61
|
+
it("documents the chat attribute on events", () => {
|
|
62
|
+
expect(p.systemPrompt).toContain("Multi-chat");
|
|
63
|
+
expect(p.systemPrompt).toContain("chat — name of the Telegram chat");
|
|
64
|
+
});
|
|
60
65
|
});
|
|
61
66
|
|
|
62
67
|
describe("userMessage", () => {
|
|
63
68
|
it("builds user message event with time attribute", () => {
|
|
64
69
|
const result = p.userMessage("check-logs", "hello");
|
|
65
|
-
expect(result).toMatch(/^<event time="\d{4}-\d{2}-\d{2}T\d{2}:\d{2}" name="check-logs" type="user-message" session="main">/);
|
|
70
|
+
expect(result).toMatch(/^<event time="\d{4}-\d{2}-\d{2}T\d{2}:\d{2}" name="check-logs" chat="admin" type="user-message" session="main">/);
|
|
66
71
|
expect(result).toContain("<text>hello</text>");
|
|
67
72
|
expect(result).toEndWith("</event>");
|
|
68
73
|
});
|
|
@@ -241,3 +246,23 @@ describe("healthCheck", () => {
|
|
|
241
246
|
expect(result).toContain("<instructions>Report status.</instructions>");
|
|
242
247
|
});
|
|
243
248
|
});
|
|
249
|
+
|
|
250
|
+
describe("chat attribute", () => {
|
|
251
|
+
it("includes chat attribute on every event type", () => {
|
|
252
|
+
const pb = new PromptBuilder("UTC", "family");
|
|
253
|
+
expect(pb.userMessage("t", "hello")).toContain('chat="family"');
|
|
254
|
+
expect(pb.buttonClick("t", "Yes")).toContain('chat="family"');
|
|
255
|
+
expect(pb.scheduleTrigger("t", { name: "daily" }, "go")).toContain('chat="family"');
|
|
256
|
+
expect(pb.backgroundAgentStart("t", "task")).toContain('chat="family"');
|
|
257
|
+
expect(pb.backgroundAgentResult("t", "orig", { action: "send", actionReason: "done" })).toContain('chat="family"');
|
|
258
|
+
expect(pb.backgroundAgentProgress("t", "orig", "50%", "keep going")).toContain('chat="family"');
|
|
259
|
+
expect(pb.peek("t", "orig", "status?")).toContain('chat="family"');
|
|
260
|
+
expect(pb.healthCheck("t", "orig", "status?")).toContain('chat="family"');
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it("escapes XML in chat name", () => {
|
|
264
|
+
const pb = new PromptBuilder("UTC", 'a<b>"');
|
|
265
|
+
const result = pb.userMessage("test", "hello");
|
|
266
|
+
expect(result).toContain('chat="a<b>""');
|
|
267
|
+
});
|
|
268
|
+
});
|
package/src/prompt-builder.ts
CHANGED
|
@@ -17,9 +17,16 @@ Use raw <b>, <i>, <code>, <pre> tags. Escape &, <, > in text content as &, &
|
|
|
17
17
|
Architecture: message bridge connecting chat interface and scheduled tasks. \
|
|
18
18
|
Persistent session — conversation history carries across messages. Workspace persists across sessions.
|
|
19
19
|
|
|
20
|
+
Multi-chat: the bridge serves multiple Telegram chats (e.g. "admin", "family", "work"). Each chat has its own \
|
|
21
|
+
isolated conversation thread with you. The chat attribute on every <event> identifies which chat the event \
|
|
22
|
+
originates from. Tailor your response to that chat — different people/contexts may expect different tone, \
|
|
23
|
+
access, and information. When responding to a scheduled task with chat="*", the same prompt is being \
|
|
24
|
+
broadcast to every authorized chat.
|
|
25
|
+
|
|
20
26
|
Event format: every incoming message is wrapped in an <event> XML block. Attributes:
|
|
21
27
|
- time — local time when the event was created (ISO 8601, minute precision).
|
|
22
28
|
- name — short identifier for this event (e.g. "check-logs", "cron-daily").
|
|
29
|
+
- chat — name of the Telegram chat this event belongs to (e.g. "admin", "family").
|
|
23
30
|
- type — what triggered this event. One of:
|
|
24
31
|
- user-message — direct user message. Content in <text>, optional <files>.
|
|
25
32
|
- button-click — user tapped an inline button. Label in <button>.
|
|
@@ -83,9 +90,11 @@ interface BuildXmlFields {
|
|
|
83
90
|
|
|
84
91
|
export class PromptBuilder {
|
|
85
92
|
readonly #timeZone: string;
|
|
93
|
+
readonly #chatName: string;
|
|
86
94
|
|
|
87
|
-
constructor(timeZone: string) {
|
|
95
|
+
constructor(timeZone: string, chatName: string) {
|
|
88
96
|
this.#timeZone = timeZone;
|
|
97
|
+
this.#chatName = chatName;
|
|
89
98
|
}
|
|
90
99
|
|
|
91
100
|
get systemPrompt(): string {
|
|
@@ -100,10 +109,10 @@ export class PromptBuilder {
|
|
|
100
109
|
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
101
110
|
}
|
|
102
111
|
|
|
103
|
-
|
|
112
|
+
#buildXml(name: string, type: string, session: string, time: string, fields: BuildXmlFields): string {
|
|
104
113
|
const esc = PromptBuilder.#escapeXml;
|
|
105
114
|
const lines: string[] = [
|
|
106
|
-
`<event time="${time}" name="${esc(name)}" type="${type}" session="${session}">`,
|
|
115
|
+
`<event time="${time}" name="${esc(name)}" chat="${esc(this.#chatName)}" type="${type}" session="${session}">`,
|
|
107
116
|
];
|
|
108
117
|
|
|
109
118
|
if (fields.backgroundedEvent) {
|
|
@@ -174,34 +183,34 @@ export class PromptBuilder {
|
|
|
174
183
|
}
|
|
175
184
|
|
|
176
185
|
userMessage(name: string, text: string, opts?: { files?: string[]; backgroundedEvent?: string }): string {
|
|
177
|
-
return
|
|
186
|
+
return this.#buildXml(name, "user-message", "main", this.#localTime(), { text, files: opts?.files, backgroundedEvent: opts?.backgroundedEvent });
|
|
178
187
|
}
|
|
179
188
|
|
|
180
189
|
buttonClick(name: string, button: string, opts?: { backgroundedEvent?: string }): string {
|
|
181
|
-
return
|
|
190
|
+
return this.#buildXml(name, "button-click", "main", this.#localTime(), { button, backgroundedEvent: opts?.backgroundedEvent });
|
|
182
191
|
}
|
|
183
192
|
|
|
184
193
|
scheduleTrigger(name: string, schedule: { name: string; missedBy?: string; scheduledAt?: string }, text: string): string {
|
|
185
|
-
return
|
|
194
|
+
return this.#buildXml(name, "schedule-trigger", "background", this.#localTime(), { schedule, text });
|
|
186
195
|
}
|
|
187
196
|
|
|
188
197
|
backgroundAgentStart(name: string, text: string): string {
|
|
189
|
-
return
|
|
198
|
+
return this.#buildXml(name, "background-agent-start", "background", this.#localTime(), { text });
|
|
190
199
|
}
|
|
191
200
|
|
|
192
201
|
backgroundAgentResult(name: string, originalEvent: string, result: { action: string; actionReason: string; text?: string; files?: string[] }, opts?: { backgroundedEvent?: string }): string {
|
|
193
|
-
return
|
|
202
|
+
return this.#buildXml(name, "background-agent-result", "main", this.#localTime(), { originalEvent, result, backgroundedEvent: opts?.backgroundedEvent });
|
|
194
203
|
}
|
|
195
204
|
|
|
196
205
|
backgroundAgentProgress(name: string, originalEvent: string, progress: string, instructions: string, opts?: { backgroundedEvent?: string }): string {
|
|
197
|
-
return
|
|
206
|
+
return this.#buildXml(name, "background-agent-progress", "main", this.#localTime(), { originalEvent, progress, instructions, backgroundedEvent: opts?.backgroundedEvent });
|
|
198
207
|
}
|
|
199
208
|
|
|
200
209
|
peek(name: string, targetEvent: string, instructions: string): string {
|
|
201
|
-
return
|
|
210
|
+
return this.#buildXml(name, "peek", "background", this.#localTime(), { targetEvent, instructions });
|
|
202
211
|
}
|
|
203
212
|
|
|
204
213
|
healthCheck(name: string, targetEvent: string, instructions: string): string {
|
|
205
|
-
return
|
|
214
|
+
return this.#buildXml(name, "health-check", "background", this.#localTime(), { targetEvent, instructions });
|
|
206
215
|
}
|
|
207
216
|
}
|