create-ironclaws 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 (80) hide show
  1. package/README.md +101 -0
  2. package/bin/create.js +394 -0
  3. package/package.json +33 -0
  4. package/template/.env.example +38 -0
  5. package/template/CLAUDE.md +104 -0
  6. package/template/agent-credentials.yaml +33 -0
  7. package/template/agents.yaml +22 -0
  8. package/template/container/Dockerfile +70 -0
  9. package/template/container/Dockerfile.argus +34 -0
  10. package/template/container/agent-runner/package-lock.json +1524 -0
  11. package/template/container/agent-runner/package.json +23 -0
  12. package/template/container/agent-runner/src/index.ts +630 -0
  13. package/template/container/agent-runner/src/ipc-mcp-stdio.ts +339 -0
  14. package/template/container/agent-runner/tsconfig.json +15 -0
  15. package/template/container/build-argus.sh +25 -0
  16. package/template/container/build.sh +23 -0
  17. package/template/container/skills/agent-browser/SKILL.md +159 -0
  18. package/template/container/skills/agent-status/SKILL.md +69 -0
  19. package/template/container/skills/capabilities/SKILL.md +100 -0
  20. package/template/container/skills/edit-agent/SKILL.md +93 -0
  21. package/template/container/skills/slack-formatting/SKILL.md +92 -0
  22. package/template/container/skills/status/SKILL.md +104 -0
  23. package/template/container/tools/elastic_query.py +161 -0
  24. package/template/container/tools/gdrive_tool.py +185 -0
  25. package/template/container/tools/jira_tool.py +433 -0
  26. package/template/container/tools/slack_history_tool.py +144 -0
  27. package/template/container/tools/youtube_tool.py +174 -0
  28. package/template/docker-compose.yml +54 -0
  29. package/template/docs/how-it-works.md +496 -0
  30. package/template/eslint.config.js +32 -0
  31. package/template/groups/forge/CLAUDE.md +107 -0
  32. package/template/package-lock.json +5278 -0
  33. package/template/package.json +52 -0
  34. package/template/scripts/github-app-token.py +58 -0
  35. package/template/scripts/register-expense-agent.sh +121 -0
  36. package/template/scripts/run-migrations.ts +105 -0
  37. package/template/scripts/setup-onecli-secrets.sh +252 -0
  38. package/template/setup-agents.sh +142 -0
  39. package/template/src/channels/index.ts +13 -0
  40. package/template/src/channels/registry.test.ts +42 -0
  41. package/template/src/channels/registry.ts +28 -0
  42. package/template/src/channels/slack.test.ts +859 -0
  43. package/template/src/channels/slack.ts +373 -0
  44. package/template/src/claw-skill.test.ts +45 -0
  45. package/template/src/config.ts +94 -0
  46. package/template/src/container-runner.test.ts +221 -0
  47. package/template/src/container-runner.ts +1029 -0
  48. package/template/src/container-runtime.test.ts +149 -0
  49. package/template/src/container-runtime.ts +124 -0
  50. package/template/src/db-migration.test.ts +67 -0
  51. package/template/src/db.test.ts +484 -0
  52. package/template/src/db.ts +837 -0
  53. package/template/src/env.ts +42 -0
  54. package/template/src/formatting.test.ts +294 -0
  55. package/template/src/github-token.ts +48 -0
  56. package/template/src/google-token.ts +75 -0
  57. package/template/src/group-folder.test.ts +43 -0
  58. package/template/src/group-folder.ts +44 -0
  59. package/template/src/group-queue.test.ts +484 -0
  60. package/template/src/group-queue.ts +363 -0
  61. package/template/src/http-server.ts +343 -0
  62. package/template/src/index.ts +960 -0
  63. package/template/src/ipc-auth.test.ts +679 -0
  64. package/template/src/ipc.ts +548 -0
  65. package/template/src/logger.ts +16 -0
  66. package/template/src/mount-security.ts +421 -0
  67. package/template/src/network-policy.ts +119 -0
  68. package/template/src/remote-control.test.ts +397 -0
  69. package/template/src/remote-control.ts +224 -0
  70. package/template/src/router.ts +52 -0
  71. package/template/src/routing.test.ts +170 -0
  72. package/template/src/sender-allowlist.test.ts +216 -0
  73. package/template/src/sender-allowlist.ts +128 -0
  74. package/template/src/task-scheduler.test.ts +129 -0
  75. package/template/src/task-scheduler.ts +290 -0
  76. package/template/src/timezone.test.ts +73 -0
  77. package/template/src/timezone.ts +37 -0
  78. package/template/src/types.ts +114 -0
  79. package/template/src/worktree.ts +206 -0
  80. package/template/tsconfig.json +20 -0
@@ -0,0 +1,373 @@
1
+ import { App, LogLevel } from '@slack/bolt';
2
+ import type { GenericMessageEvent, BotMessageEvent } from '@slack/types';
3
+
4
+ import { ASSISTANT_NAME, TRIGGER_PATTERN } from '../config.js';
5
+ import { updateChatName } from '../db.js';
6
+ import { readEnvFile } from '../env.js';
7
+ import { logger } from '../logger.js';
8
+ import { registerChannel, ChannelOpts } from './registry.js';
9
+ import {
10
+ Channel,
11
+ OnInboundMessage,
12
+ OnChatMetadata,
13
+ RegisteredGroup,
14
+ } from '../types.js';
15
+
16
+ // Slack's chat.postMessage API limits text to ~4000 characters per call.
17
+ // Messages exceeding this are split into sequential chunks.
18
+ const MAX_MESSAGE_LENGTH = 4000;
19
+
20
+ // The message subtypes we process. Bolt delivers all subtypes via app.event('message');
21
+ // we filter to regular messages (GenericMessageEvent, subtype undefined) and bot messages
22
+ // (BotMessageEvent, subtype 'bot_message') so we can track our own output.
23
+ type HandledMessageEvent = GenericMessageEvent | BotMessageEvent;
24
+
25
+ export interface SlackChannelOpts {
26
+ onMessage: OnInboundMessage;
27
+ onChatMetadata: OnChatMetadata;
28
+ registeredGroups: () => Record<string, RegisteredGroup>;
29
+ }
30
+
31
+ export class SlackChannel implements Channel {
32
+ name = 'slack';
33
+
34
+ private app: App;
35
+ private botUserId: string | undefined;
36
+ private connected = false;
37
+ private outgoingQueue: Array<{ jid: string; text: string; threadTs?: string }> = [];
38
+ private flushing = false;
39
+ private userNameCache = new Map<string, string>();
40
+ private userEmailCache = new Map<string, string>();
41
+
42
+ private opts: SlackChannelOpts;
43
+ // Tracks the active thread_ts per jid so replies go into the correct thread.
44
+ // Set when a message arrives: use msg.thread_ts if already threaded, else msg.ts.
45
+ private activeThreadTs = new Map<string, string>();
46
+
47
+ constructor(opts: SlackChannelOpts) {
48
+ this.opts = opts;
49
+
50
+ // Read tokens from .env (not process.env — keeps secrets off the environment
51
+ // so they don't leak to child processes, matching NanoClaw's security pattern)
52
+ const env = readEnvFile(['SLACK_BOT_TOKEN', 'SLACK_APP_TOKEN']);
53
+ const botToken = env.SLACK_BOT_TOKEN;
54
+ const appToken = env.SLACK_APP_TOKEN;
55
+
56
+ if (!botToken || !appToken) {
57
+ throw new Error(
58
+ 'SLACK_BOT_TOKEN and SLACK_APP_TOKEN must be set in .env',
59
+ );
60
+ }
61
+
62
+ this.app = new App({
63
+ token: botToken,
64
+ appToken,
65
+ socketMode: true,
66
+ logLevel: LogLevel.ERROR,
67
+ });
68
+
69
+ this.setupEventHandlers();
70
+ }
71
+
72
+ private setupEventHandlers(): void {
73
+ // Use app.event('message') instead of app.message() to capture all
74
+ // message subtypes including bot_message (needed to track our own output)
75
+ this.app.event('message', async ({ event }) => {
76
+ // Bolt's event type is the full MessageEvent union (17+ subtypes).
77
+ // We filter on subtype first, then narrow to the two types we handle.
78
+ const subtype = (event as { subtype?: string }).subtype;
79
+ if (subtype && subtype !== 'bot_message') return;
80
+
81
+ // After filtering, event is either GenericMessageEvent or BotMessageEvent
82
+ const msg = event as HandledMessageEvent;
83
+
84
+ if (!msg.text) return;
85
+
86
+ const jid = `slack:${msg.channel}`;
87
+ const timestamp = new Date(parseFloat(msg.ts) * 1000).toISOString();
88
+ const isGroup = msg.channel_type !== 'im';
89
+
90
+ // Always report metadata for group discovery
91
+ this.opts.onChatMetadata(jid, timestamp, undefined, 'slack', isGroup);
92
+
93
+ // Only deliver full messages for registered groups
94
+ const groups = this.opts.registeredGroups();
95
+ if (!groups[jid]) return;
96
+
97
+ const isBotMessage = !!msg.bot_id || msg.user === this.botUserId;
98
+
99
+ let senderName: string;
100
+ let senderEmail: string | undefined;
101
+ if (isBotMessage) {
102
+ senderName = ASSISTANT_NAME;
103
+ } else {
104
+ senderName =
105
+ (msg.user ? await this.resolveUserName(msg.user) : undefined) ||
106
+ msg.user ||
107
+ 'unknown';
108
+ senderEmail = msg.user ? await this.resolveUserEmail(msg.user) : undefined;
109
+ }
110
+
111
+ // Translate Slack <@UBOTID> mentions into TRIGGER_PATTERN format.
112
+ // Slack encodes @mentions as <@U12345>, which won't match TRIGGER_PATTERN
113
+ // (e.g., ^@<ASSISTANT_NAME>\b), so we prepend the trigger when the bot is @mentioned.
114
+ let content = msg.text;
115
+ if (this.botUserId && !isBotMessage) {
116
+ const mentionPattern = `<@${this.botUserId}>`;
117
+ if (
118
+ content.includes(mentionPattern) &&
119
+ !TRIGGER_PATTERN.test(content)
120
+ ) {
121
+ content = `@${ASSISTANT_NAME} ${content}`;
122
+ }
123
+ }
124
+
125
+ // Track thread context: if already in a thread use thread_ts, else start
126
+ // one from this message so replies stay in-thread.
127
+ // Groups with noThreading=true always respond in the channel, never in threads.
128
+ // We skip tracking only for our own bot's replies — external bots (e.g. Alertmanager
129
+ // webhooks) arrive as bot_messages but must still anchor the thread.
130
+ const isOurBot = this.botUserId && msg.user === this.botUserId;
131
+ if (!isOurBot) {
132
+ const noThreading = groups[jid]?.containerConfig?.noThreading;
133
+ if (!noThreading) {
134
+ const threadTs = (msg as { thread_ts?: string }).thread_ts || msg.ts;
135
+ this.activeThreadTs.set(jid, threadTs);
136
+ } else {
137
+ this.activeThreadTs.delete(jid);
138
+ }
139
+ }
140
+
141
+ this.opts.onMessage(jid, {
142
+ id: msg.ts,
143
+ chat_jid: jid,
144
+ sender: msg.user || msg.bot_id || '',
145
+ sender_name: senderName,
146
+ sender_email: senderEmail,
147
+ content,
148
+ timestamp,
149
+ is_from_me: msg.user === this.botUserId,
150
+ is_bot_message: isBotMessage,
151
+ });
152
+ });
153
+ }
154
+
155
+ async connect(): Promise<void> {
156
+ await this.app.start();
157
+
158
+ // Get bot's own user ID for self-message detection.
159
+ // Resolve this BEFORE setting connected=true so that messages arriving
160
+ // during startup can correctly detect bot-sent messages.
161
+ try {
162
+ const auth = await this.app.client.auth.test();
163
+ this.botUserId = auth.user_id as string;
164
+ logger.info({ botUserId: this.botUserId }, 'Connected to Slack');
165
+ } catch (err) {
166
+ logger.warn({ err }, 'Connected to Slack but failed to get bot user ID');
167
+ }
168
+
169
+ this.connected = true;
170
+
171
+ // Flush any messages queued before connection
172
+ await this.flushOutgoingQueue();
173
+
174
+ // Sync channel names on startup
175
+ await this.syncChannelMetadata();
176
+ }
177
+
178
+ async sendMessage(jid: string, text: string): Promise<void> {
179
+ const channelId = jid.replace(/^slack:/, '');
180
+
181
+ if (!this.connected) {
182
+ this.outgoingQueue.push({ jid, text, threadTs: this.activeThreadTs.get(jid) });
183
+ logger.info(
184
+ { jid, queueSize: this.outgoingQueue.length },
185
+ 'Slack disconnected, message queued',
186
+ );
187
+ return;
188
+ }
189
+
190
+ try {
191
+ const groups = this.opts.registeredGroups();
192
+ const noThreading = groups[jid]?.containerConfig?.noThreading;
193
+ const threadTs = noThreading ? undefined : this.activeThreadTs.get(jid);
194
+ // Slack limits messages to ~4000 characters; split if needed
195
+ if (text.length <= MAX_MESSAGE_LENGTH) {
196
+ await this.app.client.chat.postMessage({ channel: channelId, text, thread_ts: threadTs });
197
+ } else {
198
+ for (let i = 0; i < text.length; i += MAX_MESSAGE_LENGTH) {
199
+ await this.app.client.chat.postMessage({
200
+ channel: channelId,
201
+ text: text.slice(i, i + MAX_MESSAGE_LENGTH),
202
+ thread_ts: threadTs,
203
+ });
204
+ }
205
+ }
206
+ logger.info({ jid, length: text.length }, 'Slack message sent');
207
+ } catch (err) {
208
+ this.outgoingQueue.push({ jid, text });
209
+ logger.warn(
210
+ { jid, err, queueSize: this.outgoingQueue.length },
211
+ 'Failed to send Slack message, queued',
212
+ );
213
+ }
214
+ }
215
+
216
+ isConnected(): boolean {
217
+ return this.connected;
218
+ }
219
+
220
+ ownsJid(jid: string): boolean {
221
+ return jid.startsWith('slack:');
222
+ }
223
+
224
+ async disconnect(): Promise<void> {
225
+ this.connected = false;
226
+ await this.app.stop();
227
+ }
228
+
229
+ // Slack does not expose a typing indicator API for bots.
230
+ // This no-op satisfies the Channel interface so the orchestrator
231
+ // doesn't need channel-specific branching.
232
+ async setTyping(_jid: string, _isTyping: boolean): Promise<void> {
233
+ // no-op: Slack Bot API has no typing indicator endpoint
234
+ }
235
+
236
+ /**
237
+ * Sync channel metadata from Slack.
238
+ * Fetches channels the bot is a member of and stores their names in the DB.
239
+ */
240
+ async syncChannelMetadata(): Promise<void> {
241
+ try {
242
+ logger.info('Syncing channel metadata from Slack...');
243
+ let cursor: string | undefined;
244
+ let count = 0;
245
+
246
+ do {
247
+ const result = await this.app.client.conversations.list({
248
+ types: 'public_channel,private_channel',
249
+ exclude_archived: true,
250
+ limit: 200,
251
+ cursor,
252
+ });
253
+
254
+ for (const ch of result.channels || []) {
255
+ if (ch.id && ch.name && ch.is_member) {
256
+ updateChatName(`slack:${ch.id}`, ch.name);
257
+ count++;
258
+ }
259
+ }
260
+
261
+ cursor = result.response_metadata?.next_cursor || undefined;
262
+ } while (cursor);
263
+
264
+ logger.info({ count }, 'Slack channel metadata synced');
265
+ } catch (err) {
266
+ logger.error({ err }, 'Failed to sync Slack channel metadata');
267
+ }
268
+ }
269
+
270
+ async getChannelMembers(jid: string): Promise<Array<{ id: string; name: string }>> {
271
+ const channelId = jid.replace(/^slack:/, '');
272
+ const members: Array<{ id: string; name: string }> = [];
273
+ let cursor: string | undefined;
274
+
275
+ try {
276
+ do {
277
+ const result = await this.app.client.conversations.members({
278
+ channel: channelId,
279
+ limit: 200,
280
+ cursor,
281
+ });
282
+
283
+ for (const userId of result.members || []) {
284
+ // Skip our own bot user
285
+ if (userId === this.botUserId) continue;
286
+ // Skip all bot users — only real people should be mentionable
287
+ const userInfo = await this.resolveUserInfo(userId);
288
+ if (!userInfo || userInfo.isBot) continue;
289
+ if (userInfo.name) {
290
+ members.push({ id: userId, name: userInfo.name });
291
+ }
292
+ }
293
+
294
+ cursor = result.response_metadata?.next_cursor || undefined;
295
+ } while (cursor);
296
+ } catch (err) {
297
+ logger.warn({ jid, err }, 'Failed to fetch channel members');
298
+ }
299
+
300
+ return members;
301
+ }
302
+
303
+ private async resolveUserInfo(
304
+ userId: string,
305
+ ): Promise<{ name?: string; isBot: boolean } | undefined> {
306
+ if (!userId) return undefined;
307
+ try {
308
+ const result = await this.app.client.users.info({ user: userId });
309
+ const user = result.user;
310
+ if (!user) return undefined;
311
+ const name = user.real_name || user.name;
312
+ if (name) this.userNameCache.set(userId, name);
313
+ const email = user.profile?.email;
314
+ if (email) this.userEmailCache.set(userId, email);
315
+ return { name, isBot: !!(user.is_bot || user.id === 'USLACKBOT') };
316
+ } catch (err) {
317
+ logger.debug({ userId, err }, 'Failed to resolve Slack user info');
318
+ return undefined;
319
+ }
320
+ }
321
+
322
+ private async resolveUserName(userId: string): Promise<string | undefined> {
323
+ if (!userId) return undefined;
324
+ const cached = this.userNameCache.get(userId);
325
+ if (cached) return cached;
326
+ const info = await this.resolveUserInfo(userId);
327
+ return info?.name;
328
+ }
329
+
330
+ private async resolveUserEmail(userId: string): Promise<string | undefined> {
331
+ if (!userId) return undefined;
332
+ const cached = this.userEmailCache.get(userId);
333
+ if (cached) return cached;
334
+ const info = await this.resolveUserInfo(userId);
335
+ // resolveUserInfo populates the email cache, so check it
336
+ return this.userEmailCache.get(userId);
337
+ }
338
+
339
+ private async flushOutgoingQueue(): Promise<void> {
340
+ if (this.flushing || this.outgoingQueue.length === 0) return;
341
+ this.flushing = true;
342
+ try {
343
+ logger.info(
344
+ { count: this.outgoingQueue.length },
345
+ 'Flushing Slack outgoing queue',
346
+ );
347
+ while (this.outgoingQueue.length > 0) {
348
+ const item = this.outgoingQueue.shift()!;
349
+ const channelId = item.jid.replace(/^slack:/, '');
350
+ await this.app.client.chat.postMessage({
351
+ channel: channelId,
352
+ text: item.text,
353
+ thread_ts: item.threadTs,
354
+ });
355
+ logger.info(
356
+ { jid: item.jid, length: item.text.length },
357
+ 'Queued Slack message sent',
358
+ );
359
+ }
360
+ } finally {
361
+ this.flushing = false;
362
+ }
363
+ }
364
+ }
365
+
366
+ registerChannel('slack', (opts: ChannelOpts) => {
367
+ const envVars = readEnvFile(['SLACK_BOT_TOKEN', 'SLACK_APP_TOKEN']);
368
+ if (!envVars.SLACK_BOT_TOKEN || !envVars.SLACK_APP_TOKEN) {
369
+ logger.warn('Slack: SLACK_BOT_TOKEN or SLACK_APP_TOKEN not set');
370
+ return null;
371
+ }
372
+ return new SlackChannel(opts);
373
+ });
@@ -0,0 +1,45 @@
1
+ import fs from 'fs';
2
+ import os from 'os';
3
+ import path from 'path';
4
+ import { spawnSync } from 'child_process';
5
+
6
+ import { describe, expect, it } from 'vitest';
7
+
8
+ describe('claw skill script', () => {
9
+ it('exits zero after successful structured output even if the runtime is terminated', () => {
10
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'claw-skill-test-'));
11
+ const binDir = path.join(tempDir, 'bin');
12
+ fs.mkdirSync(binDir, { recursive: true });
13
+
14
+ const runtimePath = path.join(binDir, 'container');
15
+ fs.writeFileSync(
16
+ runtimePath,
17
+ `#!/bin/sh
18
+ cat >/dev/null
19
+ printf '%s\n' '---NANOCLAW_OUTPUT_START---' '{"status":"success","result":"4","newSessionId":"sess-1"}' '---NANOCLAW_OUTPUT_END---'
20
+ sleep 30
21
+ `,
22
+ );
23
+ fs.chmodSync(runtimePath, 0o755);
24
+
25
+ const result = spawnSync(
26
+ 'python3',
27
+ ['.claude/skills/claw/scripts/claw', '-j', 'tg:123', 'What is 2+2?'],
28
+ {
29
+ cwd: process.cwd(),
30
+ encoding: 'utf8',
31
+ env: {
32
+ ...process.env,
33
+ NANOCLAW_DIR: tempDir,
34
+ PATH: `${binDir}:${process.env.PATH || ''}`,
35
+ },
36
+ timeout: 15000,
37
+ },
38
+ );
39
+
40
+ expect(result.status).toBe(0);
41
+ expect(result.signal).toBeNull();
42
+ expect(result.stdout).toContain('4');
43
+ expect(result.stderr).toContain('[session: sess-1]');
44
+ });
45
+ });
@@ -0,0 +1,94 @@
1
+ import os from 'os';
2
+ import path from 'path';
3
+
4
+ import { readEnvFile } from './env.js';
5
+ import { isValidTimezone } from './timezone.js';
6
+
7
+ // Read config values from .env (falls back to process.env).
8
+ const envConfig = readEnvFile([
9
+ 'ASSISTANT_NAME',
10
+ 'ASSISTANT_HAS_OWN_NUMBER',
11
+ 'ONECLI_URL',
12
+ 'TZ',
13
+ 'CONTAINER_IMAGE',
14
+ ]);
15
+
16
+ export const ASSISTANT_NAME =
17
+ process.env.ASSISTANT_NAME || envConfig.ASSISTANT_NAME || 'Andy';
18
+ export const ASSISTANT_HAS_OWN_NUMBER =
19
+ (process.env.ASSISTANT_HAS_OWN_NUMBER ||
20
+ envConfig.ASSISTANT_HAS_OWN_NUMBER) === 'true';
21
+ export const POLL_INTERVAL = parseInt(process.env.POLL_INTERVAL || '2000', 10);
22
+ export const SCHEDULER_POLL_INTERVAL = parseInt(process.env.SCHEDULER_POLL_INTERVAL || '60000', 10);
23
+
24
+ // Absolute paths needed for container mounts
25
+ const PROJECT_ROOT = process.cwd();
26
+ const HOME_DIR = process.env.HOME || os.homedir();
27
+
28
+ // Mount security: allowlist stored OUTSIDE project root, never mounted into containers
29
+ export const MOUNT_ALLOWLIST_PATH = path.join(
30
+ HOME_DIR,
31
+ '.config',
32
+ 'nanoclaw',
33
+ 'mount-allowlist.json',
34
+ );
35
+ export const SENDER_ALLOWLIST_PATH = path.join(
36
+ HOME_DIR,
37
+ '.config',
38
+ 'nanoclaw',
39
+ 'sender-allowlist.json',
40
+ );
41
+ export const STORE_DIR = path.resolve(PROJECT_ROOT, 'store');
42
+ export const GROUPS_DIR = path.resolve(PROJECT_ROOT, 'groups');
43
+ export const DATA_DIR = path.resolve(PROJECT_ROOT, 'data');
44
+
45
+ export const CONTAINER_IMAGE =
46
+ process.env.CONTAINER_IMAGE || envConfig.CONTAINER_IMAGE || 'nanoclaw-agent:latest';
47
+ export const CONTAINER_TIMEOUT = parseInt(
48
+ process.env.CONTAINER_TIMEOUT || '1800000',
49
+ 10,
50
+ );
51
+ export const CONTAINER_MAX_OUTPUT_SIZE = parseInt(
52
+ process.env.CONTAINER_MAX_OUTPUT_SIZE || '10485760',
53
+ 10,
54
+ ); // 10MB default
55
+ export const ONECLI_URL =
56
+ process.env.ONECLI_URL || envConfig.ONECLI_URL || 'http://localhost:10254';
57
+ export const IPC_POLL_INTERVAL = parseInt(process.env.IPC_POLL_INTERVAL || '1000', 10);
58
+ export const IDLE_TIMEOUT = parseInt(process.env.IDLE_TIMEOUT || '300000', 10); // 5 min default — how long to keep container alive after last result
59
+ export const MAX_CONCURRENT_CONTAINERS = parseInt(process.env.MAX_CONCURRENT_CONTAINERS || '7', 10);
60
+ export const MAX_RETRIES = parseInt(process.env.MAX_RETRIES || '5', 10);
61
+ export const BASE_RETRY_MS = parseInt(process.env.BASE_RETRY_MS || '5000', 10);
62
+ export const TASK_CLOSE_DELAY_MS = parseInt(process.env.TASK_CLOSE_DELAY_MS || '10000', 10);
63
+
64
+ function escapeRegex(str: string): string {
65
+ return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
66
+ }
67
+
68
+ export function buildTriggerPattern(trigger: string): RegExp {
69
+ return new RegExp(`^${escapeRegex(trigger.trim())}\\b`, 'i');
70
+ }
71
+
72
+ export const DEFAULT_TRIGGER = `@${ASSISTANT_NAME}`;
73
+
74
+ export function getTriggerPattern(trigger?: string): RegExp {
75
+ const normalizedTrigger = trigger?.trim();
76
+ return buildTriggerPattern(normalizedTrigger || DEFAULT_TRIGGER);
77
+ }
78
+
79
+ export const TRIGGER_PATTERN = buildTriggerPattern(DEFAULT_TRIGGER);
80
+
81
+ // Timezone for scheduled tasks, message formatting, etc.
82
+ // Validates each candidate is a real IANA identifier before accepting.
83
+ function resolveConfigTimezone(): string {
84
+ const candidates = [
85
+ process.env.TZ,
86
+ envConfig.TZ,
87
+ Intl.DateTimeFormat().resolvedOptions().timeZone,
88
+ ];
89
+ for (const tz of candidates) {
90
+ if (tz && isValidTimezone(tz)) return tz;
91
+ }
92
+ return 'UTC';
93
+ }
94
+ export const TIMEZONE = resolveConfigTimezone();