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 CHANGED
@@ -8,8 +8,10 @@ A lightweight bridge that turns a Telegram chat into a personal AI assistant —
8
8
 
9
9
  Macroclaw runs with `dangerouslySkipPermissions` enabled. This is intentional — the bot
10
10
  is designed to run in an isolated environment (container or VM) where the workspace is
11
- the entire world. The single authorized chat ID ensures only one user can interact with
12
- the bot. See [Docker](#docker) for the recommended containerized setup.
11
+ the entire world. Access is gated by an allowlist of authorized chat IDs: an **admin
12
+ chat** configured at setup, plus any additional chats the admin authorizes at runtime
13
+ via `/chatsadd`. Every other chat is silently ignored. See [Docker](#docker) for the
14
+ recommended containerized setup.
13
15
 
14
16
  ## Requirements
15
17
 
@@ -37,16 +39,18 @@ On subsequent runs, settings are pre-filled from the file.
37
39
 
38
40
  Settings are stored in `~/.macroclaw/settings.json` and validated on startup.
39
41
 
40
- | Setting | Env var override | Default | Required |
41
- |----------------|------------------------|----------------------------|----------|
42
- | `botToken` | `TELEGRAM_BOT_TOKEN` | — | Yes |
43
- | `chatId` | `AUTHORIZED_CHAT_ID` | — | Yes |
44
- | `model` | `MODEL` | `sonnet` | No |
45
- | `workspace` | `WORKSPACE` | `~/.macroclaw-workspace` | No |
46
- | `timeZone` | `TIMEZONE` | `UTC` | No |
47
- | `openaiApiKey` | `OPENAI_API_KEY` | — | No |
48
- | `logLevel` | `LOG_LEVEL` | `info` | No |
49
- | `pinoramaUrl` | `PINORAMA_URL` | — | No |
42
+ | Setting | Env var override | Default | Required |
43
+ |-----------------|------------------------|----------------------------|----------|
44
+ | `botToken` | `TELEGRAM_BOT_TOKEN` | — | Yes |
45
+ | `adminChatId` | `ADMIN_CHAT_ID` | — | Yes |
46
+ | `model` | `MODEL` | `sonnet` | No |
47
+ | `workspace` | `WORKSPACE` | `~/.macroclaw-workspace` | No |
48
+ | `timeZone` | `TIMEZONE` | `UTC` | No |
49
+ | `openaiApiKey` | `OPENAI_API_KEY` | — | No |
50
+ | `logLevel` | `LOG_LEVEL` | `info` | No |
51
+ | `pinoramaUrl` | `PINORAMA_URL` | — | No |
52
+
53
+ `AUTHORIZED_CHAT_ID` is still accepted as a legacy alias for `ADMIN_CHAT_ID`.
50
54
 
51
55
  **`timeZone`** sets the agent's local time zone (IANA format, e.g. `Europe/Prague`, `America/New_York`). Used for the agent's clock display and scheduled event timing.
52
56
 
@@ -54,7 +58,36 @@ Settings are stored in `~/.macroclaw/settings.json` and validated on startup.
54
58
 
55
59
  Env vars take precedence over settings file values. On startup, a masked settings summary is printed showing which values were overridden by env vars.
56
60
 
57
- Session state (Claude session IDs) is stored separately in `~/.macroclaw/sessions.json`.
61
+ Session state (Claude session IDs) is stored separately in `~/.macroclaw/sessions.json`, keyed by chat name. Runtime-authorized chats are persisted in `~/.macroclaw/authorized-chats.json`.
62
+
63
+ ### Multi-chat
64
+
65
+ One admin chat is configured at setup and is always authorized. To authorize additional chats at runtime, send these commands from the admin chat:
66
+
67
+ | Command | Description |
68
+ |---------|-------------|
69
+ | `/chats` | List authorized chats |
70
+ | `/chatsadd <chatId> <name>` | Authorize a new chat. `name` is a lowercase-kebab-case label (e.g. `family`, `work`) used in cron routing and session storage. |
71
+ | `/chatsremove <name>` | Revoke authorization and clear the chat's session |
72
+ | `/chatsremove` (no arg) | Admin: show a button picker. Non-admin authorized chats: self-removal. |
73
+
74
+ Each authorized chat has its own independent Claude session — memory, context, and tone stay isolated per chat. The agent knows which chat it's responding to via a `chat="<name>"` attribute on every event.
75
+
76
+ ### Group chats and Telegram Privacy Mode
77
+
78
+ By default, Telegram puts bots in **Privacy Mode** when they're added to groups, which means the bot only receives:
79
+
80
+ - Messages that start with `/` (commands)
81
+ - Messages that @mention the bot
82
+ - Replies to the bot's own messages
83
+
84
+ Plain text in the group is filtered out by Telegram before it reaches the bot, so the bot appears silent. To let the bot see every message in a group:
85
+
86
+ 1. Message [@BotFather](https://t.me/BotFather)
87
+ 2. `/mybots` → select your bot → **Bot Settings** → **Group Privacy** → **Turn off**
88
+ 3. Kick the bot from the group and re-add it — the privacy change only applies to fresh group memberships
89
+
90
+ Private (1:1) chats are unaffected.
58
91
 
59
92
  ## Commands
60
93
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "macroclaw",
3
- "version": "0.45.0",
3
+ "version": "0.47.0",
4
4
  "description": "Telegram-to-Claude-Code bridge",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/app.test.ts CHANGED
@@ -1,8 +1,9 @@
1
1
  import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test";
2
2
  import { existsSync, rmSync } from "node:fs";
3
3
  import { App, type AppConfig } from "./app";
4
+ import { AuthorizedChats } from "./authorized-chats";
4
5
  import { type Claude, type ClaudeProcess, type ProcessState, QueryProcessError, type QueryResult } from "./claude";
5
- import { saveSessions } from "./sessions";
6
+ import { loadSessions, saveSessions } from "./sessions";
6
7
  import type { SpeechToText } from "./speech-to-text";
7
8
 
8
9
  const mockTranscribe = mock(async (_filePath: string) => "transcribed text");
@@ -50,6 +51,20 @@ mock.module("grammy", () => ({
50
51
  opts.onStart({ username: "test_bot", id: 123 });
51
52
  }
52
53
  },
54
+ InlineKeyboard: class MockInlineKeyboard {
55
+ inline_keyboard: Array<Array<{ text: string; callback_data: string }>> = [[]];
56
+ text(label: string, data: string) {
57
+ this.inline_keyboard[this.inline_keyboard.length - 1].push({ text: label, callback_data: data });
58
+ return this;
59
+ }
60
+ row() {
61
+ this.inline_keyboard.push([]);
62
+ return this;
63
+ }
64
+ },
65
+ InputFile: class MockInputFile {
66
+ constructor(public path: string) {}
67
+ },
53
68
  }));
54
69
 
55
70
  const tmpSettingsDir = "/tmp/macroclaw-test-settings";
@@ -59,7 +74,7 @@ beforeEach(() => {
59
74
  mockTranscribe.mockReset();
60
75
  mockTranscribe.mockImplementation(async () => "transcribed text");
61
76
  if (existsSync(tmpSettingsDir)) rmSync(tmpSettingsDir, { recursive: true });
62
- saveSessions({ mainSessionId: "test-session" }, tmpSettingsDir);
77
+ saveSessions({ mainSessions: { admin: "test-session" } }, tmpSettingsDir);
63
78
  });
64
79
 
65
80
  afterEach(async () => {
@@ -130,7 +145,7 @@ function sentPrompts(claude: { processes: ClaudeProcess<unknown>[] }): string[]
130
145
  function makeConfig(overrides?: Partial<AppConfig>): AppConfig {
131
146
  return {
132
147
  botToken: "test-token",
133
- authorizedChatId: "12345",
148
+ adminChatId: "12345",
134
149
  workspace: "/tmp/macroclaw-test-workspace",
135
150
  model: "sonnet",
136
151
  timeZone: "UTC",
@@ -169,13 +184,16 @@ describe("App", () => {
169
184
  expect(bot.filterHandlers.has("callback_query:data")).toBe(true);
170
185
  });
171
186
 
172
- it("registers chatid, bg, sessions, and clear commands", () => {
187
+ it("registers chatid, bg, sessions, clear, and chats commands", () => {
173
188
  const app = makeApp();
174
189
  const bot = app.bot as any;
175
190
  expect(bot.commandHandlers.has("chatid")).toBe(true);
176
191
  expect(bot.commandHandlers.has("bg")).toBe(true);
177
192
  expect(bot.commandHandlers.has("sessions")).toBe(true);
178
193
  expect(bot.commandHandlers.has("clear")).toBe(true);
194
+ expect(bot.commandHandlers.has("chats")).toBe(true);
195
+ expect(bot.commandHandlers.has("chatsadd")).toBe(true);
196
+ expect(bot.commandHandlers.has("chatsremove")).toBe(true);
179
197
  });
180
198
 
181
199
  it("registers error handler", () => {
@@ -793,11 +811,373 @@ describe("App", () => {
793
811
  handler(ctx);
794
812
  await new Promise((r) => setTimeout(r, 50));
795
813
 
796
- // No sendMessage calls for unauthorized
797
814
  expect((bot.api.sendMessage as any).mock.calls.length).toBe(0);
798
815
  });
799
816
 
817
+ describe("/chats", () => {
818
+ it("lists authorized chats for admin", async () => {
819
+ const authorizedChats = new AuthorizedChats(tmpSettingsDir);
820
+ authorizedChats.add("55555", "family");
821
+ const app = makeApp({ authorizedChats });
822
+ const bot = app.bot as any;
823
+ const handler = bot.commandHandlers.get("chats")!;
824
+
825
+ handler({ chat: { id: 12345 } });
826
+ await new Promise((r) => setTimeout(r, 50));
827
+
828
+ const calls = (bot.api.sendMessage as any).mock.calls;
829
+ const text = calls[calls.length - 1][1];
830
+ expect(text).toContain("family");
831
+ expect(text).toContain("55555");
832
+ });
833
+
834
+ it("reports no chats when list is empty", async () => {
835
+ const app = makeApp();
836
+ const bot = app.bot as any;
837
+ const handler = bot.commandHandlers.get("chats")!;
838
+
839
+ handler({ chat: { id: 12345 } });
840
+ await new Promise((r) => setTimeout(r, 50));
841
+
842
+ const calls = (bot.api.sendMessage as any).mock.calls;
843
+ expect(calls[calls.length - 1][1]).toBe("No authorized chats.");
844
+ });
845
+
846
+ it("is ignored for non-admin chats", async () => {
847
+ const app = makeApp();
848
+ const bot = app.bot as any;
849
+ const handler = bot.commandHandlers.get("chats")!;
850
+
851
+ handler({ chat: { id: 99999 } });
852
+ await new Promise((r) => setTimeout(r, 50));
853
+
854
+ expect((bot.api.sendMessage as any).mock.calls.length).toBe(0);
855
+ });
856
+ });
857
+
858
+ describe("/chatsadd", () => {
859
+ it("adds chat and creates orchestrator for it", async () => {
860
+ const app = makeApp();
861
+ const bot = app.bot as any;
862
+ const handler = bot.commandHandlers.get("chatsadd")!;
863
+
864
+ handler({ chat: { id: 12345 }, match: "55555 family" });
865
+ await new Promise((r) => setTimeout(r, 50));
866
+
867
+ const calls = (bot.api.sendMessage as any).mock.calls;
868
+ expect(calls[calls.length - 1][1]).toBe('Chat "family" (55555) authorized.');
869
+ });
870
+
871
+ it("sends usage hint when args are missing", async () => {
872
+ const app = makeApp();
873
+ const bot = app.bot as any;
874
+ const handler = bot.commandHandlers.get("chatsadd")!;
875
+
876
+ handler({ chat: { id: 12345 }, match: "55555" });
877
+ await new Promise((r) => setTimeout(r, 50));
878
+
879
+ const calls = (bot.api.sendMessage as any).mock.calls;
880
+ expect(calls[calls.length - 1][1]).toContain("Usage:");
881
+ });
882
+
883
+ it("sends usage hint when match is empty", async () => {
884
+ const app = makeApp();
885
+ const bot = app.bot as any;
886
+ const handler = bot.commandHandlers.get("chatsadd")!;
887
+
888
+ handler({ chat: { id: 12345 }, match: "" });
889
+ await new Promise((r) => setTimeout(r, 50));
890
+
891
+ const calls = (bot.api.sendMessage as any).mock.calls;
892
+ expect(calls[calls.length - 1][1]).toContain("Usage:");
893
+ });
894
+
895
+ it("sends error for invalid chat name", async () => {
896
+ const app = makeApp();
897
+ const bot = app.bot as any;
898
+ const handler = bot.commandHandlers.get("chatsadd")!;
899
+
900
+ handler({ chat: { id: 12345 }, match: "55555 InvalidName" });
901
+ await new Promise((r) => setTimeout(r, 50));
902
+
903
+ const calls = (bot.api.sendMessage as any).mock.calls;
904
+ expect(calls[calls.length - 1][1]).toContain("Error:");
905
+ });
906
+
907
+ it("sends error for duplicate chat", async () => {
908
+ const authorizedChats = new AuthorizedChats(tmpSettingsDir);
909
+ authorizedChats.add("55555", "family");
910
+ const app = makeApp({ authorizedChats });
911
+ const bot = app.bot as any;
912
+ const handler = bot.commandHandlers.get("chatsadd")!;
913
+
914
+ handler({ chat: { id: 12345 }, match: "55555 family" });
915
+ await new Promise((r) => setTimeout(r, 50));
916
+
917
+ const calls = (bot.api.sendMessage as any).mock.calls;
918
+ expect(calls[calls.length - 1][1]).toContain("Error:");
919
+ });
920
+
921
+ it("rejects chatId that matches the admin chat", async () => {
922
+ const app = makeApp();
923
+ const bot = app.bot as any;
924
+ const handler = bot.commandHandlers.get("chatsadd")!;
925
+
926
+ handler({ chat: { id: 12345 }, match: "12345 family" });
927
+ await new Promise((r) => setTimeout(r, 50));
928
+
929
+ const calls = (bot.api.sendMessage as any).mock.calls;
930
+ expect(calls[calls.length - 1][1]).toContain("already the admin chat");
931
+ });
932
+
933
+ it("is ignored for non-admin chats", async () => {
934
+ const app = makeApp();
935
+ const bot = app.bot as any;
936
+ const handler = bot.commandHandlers.get("chatsadd")!;
937
+
938
+ handler({ chat: { id: 99999 }, match: "55555 family" });
939
+ await new Promise((r) => setTimeout(r, 50));
940
+
941
+ expect((bot.api.sendMessage as any).mock.calls.length).toBe(0);
942
+ });
943
+
944
+ it("newly added chat can send messages", async () => {
945
+ const config = makeConfig();
946
+ const app = trackApp(new App(config));
947
+ const bot = app.bot as any;
948
+
949
+ const addHandler = bot.commandHandlers.get("chatsadd")!;
950
+ addHandler({ chat: { id: 12345 }, match: "55555 family" });
951
+ await new Promise((r) => setTimeout(r, 50));
952
+
953
+ const claude = config.claude as Claude & { calls: CallInfo[]; processes: ClaudeProcess<unknown>[] };
954
+ const callsBefore = claude.calls.length;
955
+
956
+ const textHandler = bot.filterHandlers.get("message:text")![0];
957
+ textHandler({ chat: { id: 55555 }, message: { text: "hello from family" } });
958
+ await new Promise((r) => setTimeout(r, 50));
959
+
960
+ expect(claude.calls.length).toBeGreaterThan(callsBefore);
961
+ });
962
+ });
963
+
964
+ describe("/chatsremove", () => {
965
+ it("removes chat and clears its session", async () => {
966
+ saveSessions({ mainSessions: { admin: "admin-sid", family: "fam-sid" } }, tmpSettingsDir);
967
+ const authorizedChats = new AuthorizedChats(tmpSettingsDir);
968
+ authorizedChats.add("55555", "family");
969
+ const app = makeApp({ authorizedChats });
970
+ const bot = app.bot as any;
971
+ const handler = bot.commandHandlers.get("chatsremove")!;
972
+
973
+ await handler({ chat: { id: 12345 }, match: "family" });
974
+ await new Promise((r) => setTimeout(r, 50));
975
+
976
+ const calls = (bot.api.sendMessage as any).mock.calls;
977
+ expect(calls[calls.length - 1][1]).toBe('Chat "family" removed.');
978
+
979
+ const sessions = loadSessions(tmpSettingsDir);
980
+ expect(sessions.mainSessions.family).toBeUndefined();
981
+ expect(sessions.mainSessions.admin).toBe("admin-sid");
982
+ });
983
+
984
+ it("reports when no chats to remove and no arg given", async () => {
985
+ const app = makeApp();
986
+ const bot = app.bot as any;
987
+ const handler = bot.commandHandlers.get("chatsremove")!;
988
+
989
+ await handler({ chat: { id: 12345 }, match: "" });
990
+ await new Promise((r) => setTimeout(r, 50));
991
+
992
+ const calls = (bot.api.sendMessage as any).mock.calls;
993
+ expect(calls[calls.length - 1][1]).toBe("No authorized chats to remove.");
994
+ });
995
+
996
+ it("sends chat-picker buttons when no arg and chats exist", async () => {
997
+ const authorizedChats = new AuthorizedChats(tmpSettingsDir);
998
+ authorizedChats.add("55555", "family");
999
+ authorizedChats.add("66666", "work");
1000
+ const app = makeApp({ authorizedChats });
1001
+ const bot = app.bot as any;
1002
+ const handler = bot.commandHandlers.get("chatsremove")!;
1003
+
1004
+ await handler({ chat: { id: 12345 }, match: "" });
1005
+ await new Promise((r) => setTimeout(r, 50));
1006
+
1007
+ const calls = (bot.api.sendMessage as any).mock.calls;
1008
+ const lastCall = calls[calls.length - 1];
1009
+ expect(lastCall[1]).toBe("Which chat to remove?");
1010
+ const keyboard = lastCall[2].reply_markup.inline_keyboard.flat();
1011
+ const labels = keyboard.map((b: any) => b.text);
1012
+ expect(labels).toEqual(["family", "work", "Dismiss"]);
1013
+ expect(keyboard.find((b: any) => b.text === "family").callback_data).toBe("chatsremove:family");
1014
+ });
1015
+
1016
+ it("button callback removes the selected chat", async () => {
1017
+ saveSessions({ mainSessions: { admin: "admin-sid", family: "fam-sid" } }, tmpSettingsDir);
1018
+ const authorizedChats = new AuthorizedChats(tmpSettingsDir);
1019
+ authorizedChats.add("55555", "family");
1020
+ const app = makeApp({ authorizedChats });
1021
+ const bot = app.bot as any;
1022
+ const handler = bot.filterHandlers.get("callback_query:data")![0];
1023
+
1024
+ const ctx = {
1025
+ chat: { id: 12345 },
1026
+ callbackQuery: { data: "chatsremove:family" },
1027
+ answerCallbackQuery: mock(async () => {}),
1028
+ editMessageReplyMarkup: mock(async () => {}),
1029
+ };
1030
+
1031
+ await handler(ctx);
1032
+ await new Promise((r) => setTimeout(r, 50));
1033
+
1034
+ expect(ctx.editMessageReplyMarkup).toHaveBeenCalledWith({ reply_markup: { inline_keyboard: [[{ text: "✓ Removed family", callback_data: "_noop" }]] } });
1035
+ const calls = (bot.api.sendMessage as any).mock.calls;
1036
+ expect(calls[calls.length - 1][1]).toBe('Chat "family" removed.');
1037
+
1038
+ const sessions = loadSessions(tmpSettingsDir);
1039
+ expect(sessions.mainSessions.family).toBeUndefined();
1040
+ });
1041
+
1042
+ it("button callback is ignored for non-admin chats", async () => {
1043
+ const authorizedChats = new AuthorizedChats(tmpSettingsDir);
1044
+ authorizedChats.add("99999", "intruder");
1045
+ const app = makeApp({ authorizedChats });
1046
+ const bot = app.bot as any;
1047
+ const handler = bot.filterHandlers.get("callback_query:data")![0];
1048
+
1049
+ const ctx = {
1050
+ chat: { id: 99999 },
1051
+ callbackQuery: { data: "chatsremove:intruder" },
1052
+ answerCallbackQuery: mock(async () => {}),
1053
+ editMessageReplyMarkup: mock(async () => {}),
1054
+ };
1055
+
1056
+ await handler(ctx);
1057
+ await new Promise((r) => setTimeout(r, 50));
1058
+
1059
+ // Intruder is still authorized
1060
+ expect(authorizedChats.byName("intruder")).toBeDefined();
1061
+ });
1062
+
1063
+ it("sends error for unknown chat name", async () => {
1064
+ const app = makeApp();
1065
+ const bot = app.bot as any;
1066
+ const handler = bot.commandHandlers.get("chatsremove")!;
1067
+
1068
+ await handler({ chat: { id: 12345 }, match: "ghost" });
1069
+ await new Promise((r) => setTimeout(r, 50));
1070
+
1071
+ const calls = (bot.api.sendMessage as any).mock.calls;
1072
+ expect(calls[calls.length - 1][1]).toContain("Error:");
1073
+ });
1074
+
1075
+ it("ignores explicit-name removal from non-admin chats", async () => {
1076
+ const app = makeApp();
1077
+ const bot = app.bot as any;
1078
+ const handler = bot.commandHandlers.get("chatsremove")!;
1079
+
1080
+ await handler({ chat: { id: 99999 }, match: "family" });
1081
+ await new Promise((r) => setTimeout(r, 50));
1082
+
1083
+ expect((bot.api.sendMessage as any).mock.calls.length).toBe(0);
1084
+ });
1085
+
1086
+ it("self-removes when called from non-admin chat with no arg", async () => {
1087
+ const authorizedChats = new AuthorizedChats(tmpSettingsDir);
1088
+ authorizedChats.add("55555", "family");
1089
+ saveSessions({ mainSessions: { admin: "a", family: "f" } }, tmpSettingsDir);
1090
+ const app = makeApp({ authorizedChats });
1091
+ const bot = app.bot as any;
1092
+ const handler = bot.commandHandlers.get("chatsremove")!;
1093
+
1094
+ await handler({ chat: { id: 55555 }, match: "" });
1095
+ await new Promise((r) => setTimeout(r, 50));
1096
+
1097
+ const calls = (bot.api.sendMessage as any).mock.calls;
1098
+ expect(calls[calls.length - 1][1]).toBe('Chat "family" removed.');
1099
+ expect(authorizedChats.byName("family")).toBeUndefined();
1100
+ expect(loadSessions(tmpSettingsDir).mainSessions.family).toBeUndefined();
1101
+ });
1102
+
1103
+ it("ignores no-arg self-removal from unauthorized non-admin chat", async () => {
1104
+ const app = makeApp();
1105
+ const bot = app.bot as any;
1106
+ const handler = bot.commandHandlers.get("chatsremove")!;
1107
+
1108
+ await handler({ chat: { id: 77777 }, match: "" });
1109
+ await new Promise((r) => setTimeout(r, 50));
1110
+
1111
+ expect((bot.api.sendMessage as any).mock.calls.length).toBe(0);
1112
+ });
1113
+ });
1114
+ });
1115
+
1116
+ describe("handleCron", () => {
1117
+ it("routes cron job with no chat to admin orchestrator", async () => {
1118
+ const config = makeConfig();
1119
+ const app = trackApp(new App(config));
1120
+
1121
+ const claude = config.claude as Claude & { calls: CallInfo[] };
1122
+ const callsBefore = claude.calls.length;
1123
+ app.handleCron("check-in", "any news?", undefined, undefined, undefined);
1124
+ await new Promise((r) => setTimeout(r, 50));
1125
+
1126
+ expect(claude.calls.length).toBeGreaterThan(callsBefore);
1127
+ });
1128
+
1129
+ it("routes cron job with chat:'admin' to admin orchestrator", async () => {
1130
+ const config = makeConfig();
1131
+ const app = trackApp(new App(config));
1132
+
1133
+ const claude = config.claude as Claude & { calls: CallInfo[] };
1134
+ const callsBefore = claude.calls.length;
1135
+ app.handleCron("check-in", "any news?", undefined, undefined, "admin");
1136
+ await new Promise((r) => setTimeout(r, 50));
1137
+
1138
+ expect(claude.calls.length).toBeGreaterThan(callsBefore);
1139
+ });
1140
+
1141
+ it("routes cron job with chat name to that chat's orchestrator", async () => {
1142
+ const authorizedChats = new AuthorizedChats(tmpSettingsDir);
1143
+ authorizedChats.add("55555", "family");
1144
+ const config = makeConfig({ authorizedChats });
1145
+ const app = trackApp(new App(config));
1146
+
1147
+ const claude = config.claude as Claude & { calls: CallInfo[] };
1148
+ const callsBefore = claude.calls.length;
1149
+ app.handleCron("family-update", "hello family", undefined, undefined, "family");
1150
+ await new Promise((r) => setTimeout(r, 50));
1151
+
1152
+ expect(claude.calls.length).toBeGreaterThan(callsBefore);
1153
+ });
1154
+
1155
+ it("broadcasts cron job with chat:'*' to all orchestrators", async () => {
1156
+ const authorizedChats = new AuthorizedChats(tmpSettingsDir);
1157
+ authorizedChats.add("55555", "family");
1158
+ const config = makeConfig({ authorizedChats });
1159
+ const app = trackApp(new App(config));
1160
+
1161
+ const claude = config.claude as Claude & { calls: CallInfo[] };
1162
+ const callsBefore = claude.calls.length;
1163
+ app.handleCron("broadcast", "everyone", undefined, undefined, "*");
1164
+ await new Promise((r) => setTimeout(r, 50));
1165
+
1166
+ // 2 orchestrators (admin + family) → at least 2 additional claude calls
1167
+ expect(claude.calls.length).toBeGreaterThanOrEqual(callsBefore + 2);
1168
+ });
1169
+
1170
+ it("warns and does nothing for unknown chat name", async () => {
1171
+ const config = makeConfig();
1172
+ const app = trackApp(new App(config));
800
1173
 
1174
+ const claude = config.claude as Claude & { calls: CallInfo[] };
1175
+ const callsBefore = claude.calls.length;
1176
+ app.handleCron("orphan", "hello?", undefined, undefined, "nobody");
1177
+ await new Promise((r) => setTimeout(r, 50));
1178
+
1179
+ expect(claude.calls.length).toBe(callsBefore);
1180
+ });
801
1181
  });
802
1182
 
803
1183
  describe("error handler", () => {
@@ -825,6 +1205,9 @@ describe("App", () => {
825
1205
  { command: "bg", description: "Spawn a background agent" },
826
1206
  { command: "sessions", description: "List running sessions" },
827
1207
  { command: "clear", description: "Clear session and start fresh" },
1208
+ { command: "chats", description: "List authorized chats (admin only)" },
1209
+ { command: "chatsadd", description: "Authorize a new chat (admin only)" },
1210
+ { command: "chatsremove", description: "Remove an authorized chat (admin only)" },
828
1211
  ]);
829
1212
  });
830
1213
  });