@0xmaxma/claude-gateway 1.0.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.
Files changed (192) hide show
  1. package/README.md +791 -0
  2. package/config.template.json +87 -0
  3. package/dist/agent/context-isolation.d.ts +28 -0
  4. package/dist/agent/context-isolation.d.ts.map +1 -0
  5. package/dist/agent/context-isolation.js +80 -0
  6. package/dist/agent/context-isolation.js.map +1 -0
  7. package/dist/agent/runner.d.ts +211 -0
  8. package/dist/agent/runner.d.ts.map +1 -0
  9. package/dist/agent/runner.js +1802 -0
  10. package/dist/agent/runner.js.map +1 -0
  11. package/dist/agent/workspace-loader.d.ts +31 -0
  12. package/dist/agent/workspace-loader.d.ts.map +1 -0
  13. package/dist/agent/workspace-loader.js +207 -0
  14. package/dist/agent/workspace-loader.js.map +1 -0
  15. package/dist/api/auth.d.ts +25 -0
  16. package/dist/api/auth.d.ts.map +1 -0
  17. package/dist/api/auth.js +77 -0
  18. package/dist/api/auth.js.map +1 -0
  19. package/dist/api/cron-router.d.ts +22 -0
  20. package/dist/api/cron-router.d.ts.map +1 -0
  21. package/dist/api/cron-router.js +197 -0
  22. package/dist/api/cron-router.js.map +1 -0
  23. package/dist/api/gateway-router.d.ts +52 -0
  24. package/dist/api/gateway-router.d.ts.map +1 -0
  25. package/dist/api/gateway-router.js +215 -0
  26. package/dist/api/gateway-router.js.map +1 -0
  27. package/dist/api/router.d.ts +5 -0
  28. package/dist/api/router.d.ts.map +1 -0
  29. package/dist/api/router.js +1098 -0
  30. package/dist/api/router.js.map +1 -0
  31. package/dist/api/skills-router.d.ts +5 -0
  32. package/dist/api/skills-router.d.ts.map +1 -0
  33. package/dist/api/skills-router.js +424 -0
  34. package/dist/api/skills-router.js.map +1 -0
  35. package/dist/api/workspace-router.d.ts +4 -0
  36. package/dist/api/workspace-router.d.ts.map +1 -0
  37. package/dist/api/workspace-router.js +149 -0
  38. package/dist/api/workspace-router.js.map +1 -0
  39. package/dist/config/loader.d.ts +16 -0
  40. package/dist/config/loader.d.ts.map +1 -0
  41. package/dist/config/loader.js +235 -0
  42. package/dist/config/loader.js.map +1 -0
  43. package/dist/config/migrator.d.ts +70 -0
  44. package/dist/config/migrator.d.ts.map +1 -0
  45. package/dist/config/migrator.js +343 -0
  46. package/dist/config/migrator.js.map +1 -0
  47. package/dist/config/watcher.d.ts +30 -0
  48. package/dist/config/watcher.d.ts.map +1 -0
  49. package/dist/config/watcher.js +170 -0
  50. package/dist/config/watcher.js.map +1 -0
  51. package/dist/cron/manager.d.ts +48 -0
  52. package/dist/cron/manager.d.ts.map +1 -0
  53. package/dist/cron/manager.js +580 -0
  54. package/dist/cron/manager.js.map +1 -0
  55. package/dist/cron/scheduler.d.ts +50 -0
  56. package/dist/cron/scheduler.d.ts.map +1 -0
  57. package/dist/cron/scheduler.js +231 -0
  58. package/dist/cron/scheduler.js.map +1 -0
  59. package/dist/discord/receiver.d.ts +17 -0
  60. package/dist/discord/receiver.d.ts.map +1 -0
  61. package/dist/discord/receiver.js +108 -0
  62. package/dist/discord/receiver.js.map +1 -0
  63. package/dist/heartbeat/history.d.ts +24 -0
  64. package/dist/heartbeat/history.d.ts.map +1 -0
  65. package/dist/heartbeat/history.js +50 -0
  66. package/dist/heartbeat/history.js.map +1 -0
  67. package/dist/heartbeat/parser.d.ts +26 -0
  68. package/dist/heartbeat/parser.d.ts.map +1 -0
  69. package/dist/heartbeat/parser.js +283 -0
  70. package/dist/heartbeat/parser.js.map +1 -0
  71. package/dist/history/cleanup.d.ts +21 -0
  72. package/dist/history/cleanup.d.ts.map +1 -0
  73. package/dist/history/cleanup.js +171 -0
  74. package/dist/history/cleanup.js.map +1 -0
  75. package/dist/history/db.d.ts +27 -0
  76. package/dist/history/db.d.ts.map +1 -0
  77. package/dist/history/db.js +323 -0
  78. package/dist/history/db.js.map +1 -0
  79. package/dist/history/media-store.d.ts +27 -0
  80. package/dist/history/media-store.d.ts.map +1 -0
  81. package/dist/history/media-store.js +158 -0
  82. package/dist/history/media-store.js.map +1 -0
  83. package/dist/history/types.d.ts +62 -0
  84. package/dist/history/types.d.ts.map +1 -0
  85. package/dist/history/types.js +3 -0
  86. package/dist/history/types.js.map +1 -0
  87. package/dist/index.d.ts +3 -0
  88. package/dist/index.d.ts.map +1 -0
  89. package/dist/index.js +483 -0
  90. package/dist/index.js.map +1 -0
  91. package/dist/logger.d.ts +3 -0
  92. package/dist/logger.d.ts.map +1 -0
  93. package/dist/logger.js +81 -0
  94. package/dist/logger.js.map +1 -0
  95. package/dist/memory/manager.d.ts +45 -0
  96. package/dist/memory/manager.d.ts.map +1 -0
  97. package/dist/memory/manager.js +178 -0
  98. package/dist/memory/manager.js.map +1 -0
  99. package/dist/session/compactor.d.ts +22 -0
  100. package/dist/session/compactor.d.ts.map +1 -0
  101. package/dist/session/compactor.js +151 -0
  102. package/dist/session/compactor.js.map +1 -0
  103. package/dist/session/process.d.ts +76 -0
  104. package/dist/session/process.d.ts.map +1 -0
  105. package/dist/session/process.js +729 -0
  106. package/dist/session/process.js.map +1 -0
  107. package/dist/session/store.d.ts +102 -0
  108. package/dist/session/store.d.ts.map +1 -0
  109. package/dist/session/store.js +533 -0
  110. package/dist/session/store.js.map +1 -0
  111. package/dist/skills/index.d.ts +9 -0
  112. package/dist/skills/index.d.ts.map +1 -0
  113. package/dist/skills/index.js +15 -0
  114. package/dist/skills/index.js.map +1 -0
  115. package/dist/skills/invoker.d.ts +16 -0
  116. package/dist/skills/invoker.d.ts.map +1 -0
  117. package/dist/skills/invoker.js +36 -0
  118. package/dist/skills/invoker.js.map +1 -0
  119. package/dist/skills/loader.d.ts +23 -0
  120. package/dist/skills/loader.d.ts.map +1 -0
  121. package/dist/skills/loader.js +183 -0
  122. package/dist/skills/loader.js.map +1 -0
  123. package/dist/skills/parser.d.ts +55 -0
  124. package/dist/skills/parser.d.ts.map +1 -0
  125. package/dist/skills/parser.js +178 -0
  126. package/dist/skills/parser.js.map +1 -0
  127. package/dist/skills/sync.d.ts +10 -0
  128. package/dist/skills/sync.d.ts.map +1 -0
  129. package/dist/skills/sync.js +130 -0
  130. package/dist/skills/sync.js.map +1 -0
  131. package/dist/skills/watcher.d.ts +16 -0
  132. package/dist/skills/watcher.d.ts.map +1 -0
  133. package/dist/skills/watcher.js +25 -0
  134. package/dist/skills/watcher.js.map +1 -0
  135. package/dist/telegram/markdown.d.ts +25 -0
  136. package/dist/telegram/markdown.d.ts.map +1 -0
  137. package/dist/telegram/markdown.js +237 -0
  138. package/dist/telegram/markdown.js.map +1 -0
  139. package/dist/telegram/receiver.d.ts +17 -0
  140. package/dist/telegram/receiver.d.ts.map +1 -0
  141. package/dist/telegram/receiver.js +104 -0
  142. package/dist/telegram/receiver.js.map +1 -0
  143. package/dist/types.d.ts +224 -0
  144. package/dist/types.d.ts.map +1 -0
  145. package/dist/types.js +3 -0
  146. package/dist/types.js.map +1 -0
  147. package/dist/ui/web-ui.d.ts +6 -0
  148. package/dist/ui/web-ui.d.ts.map +1 -0
  149. package/dist/ui/web-ui.js +227 -0
  150. package/dist/ui/web-ui.js.map +1 -0
  151. package/dist/watch/factory.d.ts +26 -0
  152. package/dist/watch/factory.d.ts.map +1 -0
  153. package/dist/watch/factory.js +43 -0
  154. package/dist/watch/factory.js.map +1 -0
  155. package/dist/webhook/manager.d.ts +34 -0
  156. package/dist/webhook/manager.d.ts.map +1 -0
  157. package/dist/webhook/manager.js +120 -0
  158. package/dist/webhook/manager.js.map +1 -0
  159. package/mcp/bun.lock +264 -0
  160. package/mcp/channel-manager.ts +115 -0
  161. package/mcp/package.json +15 -0
  162. package/mcp/router.ts +69 -0
  163. package/mcp/server.ts +129 -0
  164. package/mcp/tools/agent/handlers.ts +578 -0
  165. package/mcp/tools/agent/module.ts +163 -0
  166. package/mcp/tools/browser/module.ts +321 -0
  167. package/mcp/tools/browser/skills/open-browser/SKILL.md +89 -0
  168. package/mcp/tools/cron/client.ts +65 -0
  169. package/mcp/tools/cron/module.ts +158 -0
  170. package/mcp/tools/cron/skills/cron/SKILL.md +46 -0
  171. package/mcp/tools/discord/access.ts +197 -0
  172. package/mcp/tools/discord/client.ts +31 -0
  173. package/mcp/tools/discord/commands.ts +48 -0
  174. package/mcp/tools/discord/inbound.ts +53 -0
  175. package/mcp/tools/discord/module.ts +442 -0
  176. package/mcp/tools/discord/outbound.ts +90 -0
  177. package/mcp/tools/discord/receiver-server.ts +58 -0
  178. package/mcp/tools/discord/skills/access/SKILL.md +86 -0
  179. package/mcp/tools/discord/skills/configure/SKILL.md +53 -0
  180. package/mcp/tools/discord/threading.ts +37 -0
  181. package/mcp/tools/discord/types.ts +106 -0
  182. package/mcp/tools/skills/handlers.ts +196 -0
  183. package/mcp/tools/skills/module.ts +163 -0
  184. package/mcp/tools/telegram/module.ts +454 -0
  185. package/mcp/tools/telegram/pure.ts +224 -0
  186. package/mcp/tools/telegram/receiver-server.ts +1656 -0
  187. package/mcp/tools/telegram/skills/access/SKILL.md +169 -0
  188. package/mcp/tools/telegram/skills/configure/SKILL.md +118 -0
  189. package/mcp/tools/telegram/typing.ts +327 -0
  190. package/mcp/types.ts +128 -0
  191. package/package.json +62 -0
  192. package/resource/claude_gateway.svg +112 -0
@@ -0,0 +1,578 @@
1
+ import * as fs from 'fs';
2
+ import * as path from 'path';
3
+ import * as os from 'os';
4
+ import { writeTelegramAccess, writeDiscordAccess } from '../../../lib/pairing';
5
+
6
+ // ---------------------------------------------------------------------------
7
+ // Regex constants (exported for tests)
8
+ // ---------------------------------------------------------------------------
9
+
10
+ export const NAME_REGEX = /^[a-zA-Z][a-zA-Z0-9_-]{1,31}$/;
11
+ export const TELEGRAM_TOKEN_REGEX = /^\d{8,12}:[A-Za-z0-9_-]{35,}$/;
12
+ export const DISCORD_TOKEN_REGEX = /^\w{24,}\.\w{6}\.[\w-]{27,}$/;
13
+ // Telegram user IDs: 6–15 decimal digits
14
+ export const TELEGRAM_USER_ID_REGEX = /^\d{6,15}$/;
15
+ // Discord Snowflakes: 17–19 decimal digits
16
+ export const DISCORD_USER_ID_REGEX = /^\d{17,19}$/;
17
+
18
+ const FILENAME_SAFE_REGEX = /^[A-Z][A-Z0-9_-]*\.md$/i;
19
+ const STANDARD_STUB_FILES = ['HEARTBEAT.md', 'MEMORY.md', 'SOUL.md', 'USER.md'];
20
+
21
+ // ---------------------------------------------------------------------------
22
+ // Path helpers (local copies — same as scripts/create-agent.ts)
23
+ // ---------------------------------------------------------------------------
24
+
25
+ export function expandHome(p: string): string {
26
+ if (p.startsWith('~/') || p === '~') {
27
+ return path.join(os.homedir(), p.slice(2));
28
+ }
29
+ return p;
30
+ }
31
+
32
+ export function configPath(): string {
33
+ const envPath = process.env['GATEWAY_CONFIG'];
34
+ if (envPath) return expandHome(envPath);
35
+ return path.join(os.homedir(), '.claude-gateway', 'config.json');
36
+ }
37
+
38
+ function gatewayDir(): string {
39
+ return path.dirname(configPath());
40
+ }
41
+
42
+ function agentRootDir(agentId: string): string {
43
+ return path.join(gatewayDir(), 'agents', agentId);
44
+ }
45
+
46
+ export function workspaceDir(agentId: string): string {
47
+ return path.join(agentRootDir(agentId), 'workspace');
48
+ }
49
+
50
+ // ---------------------------------------------------------------------------
51
+ // Config I/O
52
+ // ---------------------------------------------------------------------------
53
+
54
+ interface RawConfig {
55
+ gateway: { logDir: string; timezone: string };
56
+ agents: RawAgentEntry[];
57
+ [key: string]: unknown;
58
+ }
59
+
60
+ interface RawAgentEntry {
61
+ id: string;
62
+ description: string;
63
+ workspace: string;
64
+ env: string;
65
+ telegram?: { botToken: string };
66
+ discord?: { botToken: string };
67
+ claude: { model: string; dangerouslySkipPermissions: boolean; extraFlags: string[] };
68
+ signatureEmoji?: string;
69
+ [key: string]: unknown;
70
+ }
71
+
72
+ function loadRawConfig(): RawConfig {
73
+ const cp = configPath();
74
+ return JSON.parse(fs.readFileSync(cp, 'utf8')) as RawConfig;
75
+ }
76
+
77
+ function saveRawConfig(config: RawConfig): void {
78
+ const cp = configPath();
79
+ fs.mkdirSync(path.dirname(cp), { recursive: true });
80
+ fs.writeFileSync(cp, JSON.stringify(config, null, 2), 'utf8');
81
+ }
82
+
83
+ // ---------------------------------------------------------------------------
84
+ // Token verification (fetch — Bun native, no https module)
85
+ // ---------------------------------------------------------------------------
86
+
87
+ export async function verifyTelegramToken(token: string): Promise<{ ok: boolean; username: string }> {
88
+ try {
89
+ const res = await fetch(`https://api.telegram.org/bot${token}/getMe`);
90
+ const json = await res.json() as { ok: boolean; result?: { username?: string } };
91
+ if (json.ok && json.result?.username) {
92
+ return { ok: true, username: json.result.username };
93
+ }
94
+ return { ok: false, username: '' };
95
+ } catch {
96
+ return { ok: false, username: '' };
97
+ }
98
+ }
99
+
100
+ export async function verifyDiscordToken(token: string): Promise<{ ok: boolean; username: string }> {
101
+ try {
102
+ const res = await fetch('https://discord.com/api/v10/users/@me', {
103
+ headers: { Authorization: `Bot ${token}` },
104
+ });
105
+ const json = await res.json() as { username?: string };
106
+ return { ok: res.status === 200, username: json.username ?? '' };
107
+ } catch {
108
+ return { ok: false, username: '' };
109
+ }
110
+ }
111
+
112
+ // ---------------------------------------------------------------------------
113
+ // Helpers
114
+ // ---------------------------------------------------------------------------
115
+
116
+ function firstNonEmptyLine(text: string): string {
117
+ for (const line of text.split('\n')) {
118
+ const trimmed = line.replace(/^#+\s*/, '').trim();
119
+ if (trimmed) return trimmed;
120
+ }
121
+ return text.trim().slice(0, 80);
122
+ }
123
+
124
+ function envVarName(agentId: string, channel: 'telegram' | 'discord'): string {
125
+ const base = agentId.toUpperCase().replace(/-/g, '_');
126
+ return channel === 'telegram' ? `${base}_BOT_TOKEN` : `${base}_DISCORD_BOT_TOKEN`;
127
+ }
128
+
129
+ function howToFindUserId(channel: 'telegram' | 'discord'): string {
130
+ const tool = channel === 'telegram' ? 'telegram:access' : 'discord:discord-access';
131
+ return `No user_id provided — allowFrom is empty. Use the ${tool} tool to add users later.`;
132
+ }
133
+
134
+ // ---------------------------------------------------------------------------
135
+ // agent_create
136
+ // ---------------------------------------------------------------------------
137
+
138
+ export interface CreateAgentArgs {
139
+ id: string;
140
+ description: string;
141
+ channel: 'telegram' | 'discord';
142
+ bot_token: string;
143
+ /** @deprecated use telegram_user_id or discord_user_id */
144
+ user_id?: string;
145
+ telegram_user_id?: string;
146
+ discord_user_id?: string;
147
+ dm_policy?: 'open' | 'allowlist' | 'pairing';
148
+ model?: string;
149
+ signature_emoji?: string;
150
+ agents_md?: string;
151
+ soul_md?: string;
152
+ user_md?: string;
153
+ }
154
+
155
+ export async function createAgent(args: CreateAgentArgs): Promise<string> {
156
+ const { id, description, channel, bot_token } = args;
157
+ const dmPolicy = args.dm_policy ?? 'allowlist';
158
+ const model = args.model ?? 'claude-sonnet-4-6';
159
+
160
+ // 1. Validate id
161
+ if (!NAME_REGEX.test(id)) {
162
+ throw new Error(
163
+ `Invalid agent id "${id}": must start with a letter, 2-32 chars, letters/numbers/underscores/hyphens only`
164
+ );
165
+ }
166
+ const agentId = id.toLowerCase();
167
+
168
+ // 2. Validate description
169
+ if (!description || description.trim().length === 0) {
170
+ throw new Error('description is required and must not be empty');
171
+ }
172
+
173
+ // 3. Validate channel
174
+ if (channel !== 'telegram' && channel !== 'discord') {
175
+ throw new Error(`Unsupported channel "${channel}". Must be "telegram" or "discord"`);
176
+ }
177
+
178
+ // 4. Validate token format
179
+ if (channel === 'telegram' && !TELEGRAM_TOKEN_REGEX.test(bot_token)) {
180
+ throw new Error('Invalid Telegram bot token format. Expected: 123456789:AAHfiq...');
181
+ }
182
+ if (channel === 'discord' && !DISCORD_TOKEN_REGEX.test(bot_token)) {
183
+ throw new Error('Invalid Discord bot token format. Expected: MTAxMTk....AAAA.xxx...');
184
+ }
185
+
186
+ // 5. Check uniqueness in config.json before any file writes
187
+ let config: RawConfig;
188
+ try {
189
+ config = loadRawConfig();
190
+ } catch (err) {
191
+ throw new Error(`Cannot read config.json: ${(err as Error).message}`);
192
+ }
193
+ if (config.agents.some((a) => a.id === agentId)) {
194
+ throw new Error(`Agent "${agentId}" already exists in config.json`);
195
+ }
196
+
197
+ // 6. Verify token via upstream API (fail fast before any file writes)
198
+ let botUsername: string;
199
+ if (channel === 'telegram') {
200
+ const result = await verifyTelegramToken(bot_token);
201
+ if (!result.ok) {
202
+ throw new Error('Telegram token verification failed: getMe returned not ok. Check the token.');
203
+ }
204
+ botUsername = result.username;
205
+ } else {
206
+ const result = await verifyDiscordToken(bot_token);
207
+ if (!result.ok) {
208
+ throw new Error('Discord token verification failed: /api/v10/users/@me returned non-200. Check the token.');
209
+ }
210
+ botUsername = result.username;
211
+ }
212
+
213
+ // 7. Create workspace directory
214
+ const wsDir = workspaceDir(agentId);
215
+ fs.mkdirSync(wsDir, { recursive: true });
216
+
217
+ // 8. Write workspace files
218
+ const agentsMdContent =
219
+ args.agents_md ??
220
+ `# Agent: ${agentId.charAt(0).toUpperCase() + agentId.slice(1)}\n\n${description}`;
221
+ fs.writeFileSync(path.join(wsDir, 'AGENTS.md'), agentsMdContent, 'utf8');
222
+
223
+ if (args.soul_md) {
224
+ fs.writeFileSync(path.join(wsDir, 'SOUL.md'), args.soul_md, 'utf8');
225
+ }
226
+ if (args.user_md) {
227
+ fs.writeFileSync(path.join(wsDir, 'USER.md'), args.user_md, 'utf8');
228
+ }
229
+
230
+ // Write stub files for any standard files not yet present
231
+ for (const stub of STANDARD_STUB_FILES) {
232
+ const stubPath = path.join(wsDir, stub);
233
+ if (!fs.existsSync(stubPath)) {
234
+ fs.writeFileSync(stubPath, '', 'utf8');
235
+ }
236
+ }
237
+
238
+ // 9. Write .env for the agent (mode 0o600 — owner read-only)
239
+ const envVar = envVarName(agentId, channel);
240
+ const agentEnvFile = path.join(agentRootDir(agentId), '.env');
241
+ fs.writeFileSync(agentEnvFile, `${envVar}=${bot_token}\n`, { mode: 0o600 });
242
+
243
+ // 10. Create channel state dir + write access.json immediately (no pairing flow)
244
+ const stateDir = path.join(wsDir, `.${channel}-state`);
245
+ fs.mkdirSync(stateDir, { recursive: true });
246
+
247
+ const rawUserId = (channel === 'telegram'
248
+ ? (args.telegram_user_id ?? args.user_id)
249
+ : (args.discord_user_id ?? args.user_id)
250
+ )?.trim() ?? '';
251
+
252
+ // Validate ID format to prevent cross-channel mix-ups
253
+ if (rawUserId) {
254
+ if (channel === 'telegram' && !TELEGRAM_USER_ID_REGEX.test(rawUserId)) {
255
+ throw new Error(
256
+ `Invalid telegram_user_id "${rawUserId}": must be 6–15 digits. ` +
257
+ `Discord Snowflake IDs (17–19 digits) are NOT valid Telegram IDs.`
258
+ );
259
+ }
260
+ if (channel === 'discord' && !DISCORD_USER_ID_REGEX.test(rawUserId)) {
261
+ throw new Error(
262
+ `Invalid discord_user_id "${rawUserId}": must be 17–19 digits (Discord Snowflake). ` +
263
+ `Telegram user IDs (6–15 digits) are NOT valid Discord IDs.`
264
+ );
265
+ }
266
+ }
267
+ const userId = rawUserId;
268
+ if (channel === 'telegram') {
269
+ if (userId) {
270
+ writeTelegramAccess(stateDir, userId);
271
+ } else {
272
+ fs.writeFileSync(
273
+ path.join(stateDir, 'access.json'),
274
+ JSON.stringify({ dmPolicy: 'allowlist', allowFrom: [], groups: {}, pending: {} }, null, 2),
275
+ { mode: 0o600 }
276
+ );
277
+ }
278
+ } else {
279
+ if (userId) {
280
+ writeDiscordAccess(stateDir, userId);
281
+ } else {
282
+ fs.writeFileSync(
283
+ path.join(stateDir, 'access.json'),
284
+ JSON.stringify(
285
+ { dmPolicy: 'allowlist', allowFrom: [], guildAllowlist: [], channelAllowlist: [], roleAllowlist: [], pending: {} },
286
+ null,
287
+ 2
288
+ ) + '\n',
289
+ { mode: 0o600 }
290
+ );
291
+ }
292
+ }
293
+
294
+ // 11. Update config.json (gateway hot-adds the agent automatically)
295
+ const descriptionText = firstNonEmptyLine(agentsMdContent);
296
+ const newAgent: RawAgentEntry = {
297
+ id: agentId,
298
+ description: descriptionText,
299
+ workspace: wsDir.replace(os.homedir(), '~'),
300
+ env: '',
301
+ claude: {
302
+ model,
303
+ dangerouslySkipPermissions: true,
304
+ extraFlags: [],
305
+ },
306
+ };
307
+
308
+ if (channel === 'telegram') {
309
+ newAgent.telegram = { botToken: `\${${envVar}}` };
310
+ } else {
311
+ newAgent.discord = { botToken: `\${${envVar}}` };
312
+ }
313
+
314
+ if (args.signature_emoji) {
315
+ newAgent.signatureEmoji = args.signature_emoji;
316
+ }
317
+
318
+ config.agents.push(newAgent);
319
+ saveRawConfig(config);
320
+
321
+ const accessNote = userId
322
+ ? `Access granted to user ID: ${userId}`
323
+ : howToFindUserId(channel);
324
+
325
+ return [
326
+ `Agent "${agentId}" created successfully!`,
327
+ `Bot: @${botUsername}`,
328
+ `Workspace: ${wsDir.replace(os.homedir(), '~')}`,
329
+ `Channel: ${channel} (dm_policy: ${dmPolicy})`,
330
+ '',
331
+ accessNote,
332
+ '',
333
+ 'The gateway will detect the new agent and start it automatically (hot-add).',
334
+ 'If the agent does not appear, restart the gateway: make restart (or: pm2 restart gateway)',
335
+ ].join('\n');
336
+ }
337
+
338
+ // ---------------------------------------------------------------------------
339
+ // agent_update
340
+ // ---------------------------------------------------------------------------
341
+
342
+ export interface UpdateAgentArgs {
343
+ id: string;
344
+ action: 'add_channel' | 'remove_channel' | 'update_workspace_file';
345
+ channel?: 'telegram' | 'discord';
346
+ bot_token?: string;
347
+ /** @deprecated use telegram_user_id or discord_user_id */
348
+ user_id?: string;
349
+ telegram_user_id?: string;
350
+ discord_user_id?: string;
351
+ dm_policy?: 'open' | 'allowlist' | 'pairing';
352
+ filename?: string;
353
+ content?: string;
354
+ }
355
+
356
+ export async function updateAgent(args: UpdateAgentArgs): Promise<string> {
357
+ const { id, action } = args;
358
+
359
+ // Validate id
360
+ if (!NAME_REGEX.test(id)) {
361
+ throw new Error(`Invalid agent id "${id}"`);
362
+ }
363
+ const agentId = id.toLowerCase();
364
+
365
+ // Load config
366
+ let config: RawConfig;
367
+ try {
368
+ config = loadRawConfig();
369
+ } catch (err) {
370
+ throw new Error(`Cannot read config.json: ${(err as Error).message}`);
371
+ }
372
+
373
+ const agentIdx = config.agents.findIndex((a) => a.id === agentId);
374
+ if (agentIdx < 0) {
375
+ throw new Error(`Agent "${agentId}" not found in config.json`);
376
+ }
377
+ const agent = config.agents[agentIdx];
378
+ const wsDir = expandHome(agent.workspace);
379
+
380
+ // ── Action: add_channel ──────────────────────────────────────────────────
381
+ if (action === 'add_channel') {
382
+ const channel = args.channel;
383
+ const bot_token = args.bot_token;
384
+ const dmPolicy = args.dm_policy ?? 'allowlist';
385
+
386
+ if (!channel || (channel !== 'telegram' && channel !== 'discord')) {
387
+ throw new Error('add_channel requires channel: "telegram" or "discord"');
388
+ }
389
+ if (!bot_token) {
390
+ throw new Error('add_channel requires bot_token');
391
+ }
392
+
393
+ // Assert channel not already configured
394
+ if (channel === 'telegram' && agent.telegram?.botToken) {
395
+ throw new Error(`Agent "${agentId}" already has a Telegram channel configured`);
396
+ }
397
+ if (channel === 'discord' && agent.discord?.botToken) {
398
+ throw new Error(`Agent "${agentId}" already has a Discord channel configured`);
399
+ }
400
+
401
+ // Validate token format
402
+ if (channel === 'telegram' && !TELEGRAM_TOKEN_REGEX.test(bot_token)) {
403
+ throw new Error('Invalid Telegram bot token format');
404
+ }
405
+ if (channel === 'discord' && !DISCORD_TOKEN_REGEX.test(bot_token)) {
406
+ throw new Error('Invalid Discord bot token format');
407
+ }
408
+
409
+ // Verify token
410
+ let botUsername: string;
411
+ if (channel === 'telegram') {
412
+ const result = await verifyTelegramToken(bot_token);
413
+ if (!result.ok) throw new Error('Telegram token verification failed');
414
+ botUsername = result.username;
415
+ } else {
416
+ const result = await verifyDiscordToken(bot_token);
417
+ if (!result.ok) throw new Error('Discord token verification failed');
418
+ botUsername = result.username;
419
+ }
420
+
421
+ // Append token to agent .env
422
+ const envVar = envVarName(agentId, channel);
423
+ const agentEnvFile = path.join(agentRootDir(agentId), '.env');
424
+ fs.mkdirSync(path.dirname(agentEnvFile), { recursive: true });
425
+ let existing = '';
426
+ try {
427
+ existing = fs.readFileSync(agentEnvFile, 'utf8');
428
+ } catch {}
429
+ if (!existing.includes(`${envVar}=`)) {
430
+ fs.appendFileSync(agentEnvFile, `${envVar}=${bot_token}\n`, { mode: 0o600 });
431
+ }
432
+
433
+ // Create state dir + write access.json immediately (no pairing flow)
434
+ const stateDir = path.join(wsDir, `.${channel}-state`);
435
+ fs.mkdirSync(stateDir, { recursive: true });
436
+
437
+ const rawAddUserId = (channel === 'telegram'
438
+ ? (args.telegram_user_id ?? args.user_id)
439
+ : (args.discord_user_id ?? args.user_id)
440
+ )?.trim() ?? '';
441
+
442
+ // Validate ID format to prevent cross-channel mix-ups
443
+ if (rawAddUserId) {
444
+ if (channel === 'telegram' && !TELEGRAM_USER_ID_REGEX.test(rawAddUserId)) {
445
+ throw new Error(
446
+ `Invalid telegram_user_id "${rawAddUserId}": must be 6–15 digits. ` +
447
+ `Discord Snowflake IDs (17–19 digits) are NOT valid Telegram IDs.`
448
+ );
449
+ }
450
+ if (channel === 'discord' && !DISCORD_USER_ID_REGEX.test(rawAddUserId)) {
451
+ throw new Error(
452
+ `Invalid discord_user_id "${rawAddUserId}": must be 17–19 digits (Discord Snowflake). ` +
453
+ `Telegram user IDs (6–15 digits) are NOT valid Discord IDs.`
454
+ );
455
+ }
456
+ }
457
+ const addUserId = rawAddUserId;
458
+
459
+ if (channel === 'telegram') {
460
+ if (addUserId) {
461
+ writeTelegramAccess(stateDir, addUserId);
462
+ } else {
463
+ fs.writeFileSync(
464
+ path.join(stateDir, 'access.json'),
465
+ JSON.stringify({ dmPolicy: 'allowlist', allowFrom: [], groups: {}, pending: {} }, null, 2),
466
+ { mode: 0o600 }
467
+ );
468
+ }
469
+ } else {
470
+ if (addUserId) {
471
+ writeDiscordAccess(stateDir, addUserId);
472
+ } else {
473
+ fs.writeFileSync(
474
+ path.join(stateDir, 'access.json'),
475
+ JSON.stringify(
476
+ { dmPolicy: 'allowlist', allowFrom: [], guildAllowlist: [], channelAllowlist: [], roleAllowlist: [], pending: {} },
477
+ null,
478
+ 2
479
+ ) + '\n',
480
+ { mode: 0o600 }
481
+ );
482
+ }
483
+ }
484
+
485
+ // Update config.json (so gateway can hot-add)
486
+ if (channel === 'telegram') {
487
+ config.agents[agentIdx].telegram = { botToken: `\${${envVar}}` };
488
+ } else {
489
+ config.agents[agentIdx].discord = { botToken: `\${${envVar}}` };
490
+ }
491
+ saveRawConfig(config);
492
+
493
+ const addAccessNote = addUserId
494
+ ? `Access granted to user ID: ${addUserId}`
495
+ : howToFindUserId(channel);
496
+
497
+ return [
498
+ `Channel "${channel}" added to agent "${agentId}". Bot: @${botUsername}`,
499
+ '',
500
+ addAccessNote,
501
+ '',
502
+ 'If the channel does not connect, restart the gateway: make restart (or: pm2 restart gateway)',
503
+ ].join('\n');
504
+ }
505
+
506
+ // ── Action: remove_channel ───────────────────────────────────────────────
507
+ if (action === 'remove_channel') {
508
+ const channel = args.channel;
509
+ if (!channel || (channel !== 'telegram' && channel !== 'discord')) {
510
+ throw new Error('remove_channel requires channel: "telegram" or "discord"');
511
+ }
512
+
513
+ // Assert channel exists on agent
514
+ if (channel === 'telegram' && !agent.telegram?.botToken) {
515
+ throw new Error(`Agent "${agentId}" does not have a Telegram channel configured`);
516
+ }
517
+ if (channel === 'discord' && !agent.discord?.botToken) {
518
+ throw new Error(`Agent "${agentId}" does not have a Discord channel configured`);
519
+ }
520
+
521
+ // Remove state dir
522
+ const stateDir = path.join(wsDir, `.${channel}-state`);
523
+ try {
524
+ fs.rmSync(stateDir, { recursive: true, force: true });
525
+ } catch {}
526
+
527
+ // Strip token line from agent .env
528
+ const envVar = envVarName(agentId, channel);
529
+ const agentEnvFile = path.join(agentRootDir(agentId), '.env');
530
+ try {
531
+ const lines = fs
532
+ .readFileSync(agentEnvFile, 'utf8')
533
+ .split('\n')
534
+ .filter((l) => !l.startsWith(`${envVar}=`));
535
+ fs.writeFileSync(agentEnvFile, lines.join('\n'), { mode: 0o600 });
536
+ } catch {}
537
+
538
+ // Remove from config.json
539
+ if (channel === 'telegram') {
540
+ delete config.agents[agentIdx].telegram;
541
+ } else {
542
+ delete config.agents[agentIdx].discord;
543
+ }
544
+ saveRawConfig(config);
545
+
546
+ return `Channel "${channel}" removed from agent "${agentId}".`;
547
+ }
548
+
549
+ // ── Action: update_workspace_file ────────────────────────────────────────
550
+ if (action === 'update_workspace_file') {
551
+ const filename = args.filename;
552
+ const content = args.content;
553
+
554
+ if (!filename) throw new Error('update_workspace_file requires filename');
555
+ if (content === undefined) throw new Error('update_workspace_file requires content');
556
+
557
+ // Validate filename — no path traversal, no path components
558
+ if (
559
+ !FILENAME_SAFE_REGEX.test(filename) ||
560
+ filename.includes('/') ||
561
+ filename.includes('\\') ||
562
+ filename.includes('..')
563
+ ) {
564
+ throw new Error(
565
+ 'Invalid filename. Must match /^[A-Z][A-Z0-9_-]*\\.md$/i with no path components'
566
+ );
567
+ }
568
+
569
+ const filePath = path.join(wsDir, filename);
570
+ fs.writeFileSync(filePath, content, 'utf8');
571
+
572
+ return `File "${filename}" updated in workspace for agent "${agentId}". Gateway workspace-watcher will auto-reload CLAUDE.md.`;
573
+ }
574
+
575
+ throw new Error(
576
+ `Unknown action "${action}". Must be one of: add_channel, remove_channel, update_workspace_file`
577
+ );
578
+ }