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.
@@ -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
+ }
@@ -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", chatId: "123" })),
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", chatId: "123", model: "sonnet", workspace: "/tmp" })),
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", chatId: "123" });
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", chatId: "999" };
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", chatId: "123" }; },
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", chatId: "123", model: "opus", workspace: "/tmp" }));
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", chatId: "123", model: "sonnet", workspace: "/tmp" }));
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 {loadSessions} from "./sessions";
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 sessions = loadSessions(this.#settingsManager.dir);
35
+ const adminSession = getMainSession("admin", this.#settingsManager.dir);
36
36
  const args = ["claude"];
37
- if (sessions.mainSessionId) args.push("--resume", sessions.mainSessionId);
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
- authorizedChatId: resolved.chatId,
40
+ adminChatId: resolved.adminChatId,
41
41
  workspace,
42
42
  model: resolved.model,
43
43
  timeZone: resolved.timeZone,
@@ -263,7 +263,7 @@ describe("Orchestrator", () => {
263
263
  });
264
264
 
265
265
  it("reports error when resume fails", async () => {
266
- saveSessions({ mainSessionId: "old-session" }, tmpSettingsDir);
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({ mainSessionId: "existing-session" }, tmpSettingsDir);
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({ mainSessionId: "main-session" }, tmpSettingsDir);
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({ mainSessionId: "main-session" }, tmpSettingsDir);
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({ mainSessionId: "main-session" }, tmpSettingsDir);
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({ mainSessionId: "main-session" }, tmpSettingsDir);
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({ mainSessionId: "main-session" }, tmpSettingsDir);
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({ mainSessionId: "test-session" }, tmpSettingsDir);
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({ mainSessionId: "test-session" }, tmpSettingsDir);
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({ mainSessionId: "test-session" }, tmpSettingsDir);
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({ mainSessionId: "test-session" }, tmpSettingsDir);
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({ mainSessionId: "test-session" }, tmpSettingsDir);
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({ mainSessionId: "test-session" }, tmpSettingsDir);
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({ mainSessionId: "test-session" }, tmpSettingsDir);
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({ mainSessionId: "test-session" }, tmpSettingsDir);
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({ mainSessionId: "test-session" }, tmpSettingsDir);
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({ mainSessionId: "test-session" }, tmpSettingsDir);
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({ mainSessionId: "test-session" }, tmpSettingsDir);
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({ mainSessionId: "test-session" }, tmpSettingsDir);
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({ mainSessionId: "test-session" }, tmpSettingsDir);
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({ mainSessionId: "test-session" }, tmpSettingsDir);
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({ mainSessionId: "test-session" }, tmpSettingsDir);
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({ mainSessionId: "test-session" }, tmpSettingsDir);
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({ mainSessionId: "test-session" }, tmpSettingsDir);
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({ mainSessionId: "test-session" }, tmpSettingsDir);
1109
+ saveSessions({ mainSessions: { admin: "test-session" } }, tmpSettingsDir);
1110
1110
  const { process: bgProc, resolve: resolveBg } = pendingProcess("bg-sid");
1111
1111
 
1112
1112
  let hcCount = 0;
@@ -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 { loadSessions, saveSessions } from "./sessions";
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.#prompts = new PromptBuilder(config.timeZone);
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 = loadSessions(config.settingsDir).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
- saveSessions({}, this.#config.settingsDir);
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
- saveSessions({ mainSessionId: this.#mainSessionId }, this.#config.settingsDir);
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
- saveSessions({ mainSessionId: this.#mainSessionId }, this.#config.settingsDir);
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&lt;b&gt;&quot;"');
267
+ });
268
+ });
@@ -17,9 +17,16 @@ Use raw <b>, <i>, <code>, <pre> tags. Escape &, <, > in text content as &amp;, &
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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
101
110
  }
102
111
 
103
- static #buildXml(name: string, type: string, session: string, time: string, fields: BuildXmlFields): string {
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 PromptBuilder.#buildXml(name, "user-message", "main", this.#localTime(), { text, files: opts?.files, backgroundedEvent: opts?.backgroundedEvent });
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 PromptBuilder.#buildXml(name, "button-click", "main", this.#localTime(), { button, backgroundedEvent: opts?.backgroundedEvent });
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 PromptBuilder.#buildXml(name, "schedule-trigger", "background", this.#localTime(), { schedule, text });
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 PromptBuilder.#buildXml(name, "background-agent-start", "background", this.#localTime(), { text });
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 PromptBuilder.#buildXml(name, "background-agent-result", "main", this.#localTime(), { originalEvent, result, backgroundedEvent: opts?.backgroundedEvent });
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 PromptBuilder.#buildXml(name, "background-agent-progress", "main", this.#localTime(), { originalEvent, progress, instructions, backgroundedEvent: opts?.backgroundedEvent });
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 PromptBuilder.#buildXml(name, "peek", "background", this.#localTime(), { targetEvent, instructions });
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 PromptBuilder.#buildXml(name, "health-check", "background", this.#localTime(), { targetEvent, instructions });
214
+ return this.#buildXml(name, "health-check", "background", this.#localTime(), { targetEvent, instructions });
206
215
  }
207
216
  }