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,960 @@
1
+ import crypto from 'crypto';
2
+ import { execSync } from 'child_process';
3
+ import fs from 'fs';
4
+ import path from 'path';
5
+
6
+ import { readEnvFile } from './env.js';
7
+
8
+ import { OneCLI } from '@onecli-sh/sdk';
9
+ import YAML from 'yaml';
10
+
11
+
12
+ import {
13
+ ASSISTANT_NAME,
14
+ DEFAULT_TRIGGER,
15
+ getTriggerPattern,
16
+ GROUPS_DIR,
17
+ IDLE_TIMEOUT,
18
+ ONECLI_URL,
19
+ POLL_INTERVAL,
20
+ SENDER_ALLOWLIST_PATH,
21
+ STORE_DIR,
22
+ TIMEZONE,
23
+ } from './config.js';
24
+ import './channels/index.js';
25
+ import {
26
+ getChannelFactory,
27
+ getRegisteredChannelNames,
28
+ } from './channels/registry.js';
29
+ import {
30
+ ContainerOutput,
31
+ runContainerAgent,
32
+ writeGroupsSnapshot,
33
+ writeTasksSnapshot,
34
+ } from './container-runner.js';
35
+ import {
36
+ cleanupOrphans,
37
+ cleanupStaleIptablesRules,
38
+ ensureContainerRuntimeRunning,
39
+ } from './container-runtime.js';
40
+ import {
41
+ getAllChats,
42
+ getAllRegisteredGroups,
43
+ clearSession,
44
+ getAllSessions,
45
+ getAllTasks,
46
+ getMessagesSince,
47
+ getNewMessages,
48
+ getRouterState,
49
+ initDatabase,
50
+ setRegisteredGroup,
51
+ setRouterState,
52
+ setSession,
53
+ storeChatMetadata,
54
+ storeMessage,
55
+ } from './db.js';
56
+ import { GroupQueue } from './group-queue.js';
57
+ import { resolveGroupFolderPath } from './group-folder.js';
58
+ import { startHttpServer } from './http-server.js';
59
+ import { startIpcWatcher } from './ipc.js';
60
+ import { findChannel, formatMessages, formatOutbound } from './router.js';
61
+ import {
62
+ restoreRemoteControl,
63
+ startRemoteControl,
64
+ stopRemoteControl,
65
+ } from './remote-control.js';
66
+ import {
67
+ isSenderAllowed,
68
+ isTriggerAllowed,
69
+ loadSenderAllowlist,
70
+ shouldDropMessage,
71
+ } from './sender-allowlist.js';
72
+ import { startSchedulerLoop } from './task-scheduler.js';
73
+ import { Channel, NewMessage, RegisteredGroup } from './types.js';
74
+ import { logger } from './logger.js';
75
+
76
+ // Re-export for backwards compatibility during refactor
77
+ export { escapeXml, formatMessages } from './router.js';
78
+
79
+ let lastTimestamp = '';
80
+ let sessions: Record<string, string> = {};
81
+ let registeredGroups: Record<string, RegisteredGroup> = {};
82
+ let lastAgentTimestamp: Record<string, string> = {};
83
+ let messageLoopRunning = false;
84
+
85
+ const channels: Channel[] = [];
86
+ const queue = new GroupQueue();
87
+
88
+ const onecli = new OneCLI({ url: ONECLI_URL });
89
+
90
+ function ensureOneCLIAgent(jid: string, group: RegisteredGroup): void {
91
+ if (group.isMain) return;
92
+ const identifier = group.folder.toLowerCase().replace(/_/g, '-');
93
+ onecli.ensureAgent({ name: group.name, identifier }).then(
94
+ (res) => {
95
+ logger.info(
96
+ { jid, identifier, created: res.created },
97
+ 'OneCLI agent ensured',
98
+ );
99
+ },
100
+ (err) => {
101
+ logger.debug(
102
+ { jid, identifier, err: String(err) },
103
+ 'OneCLI agent ensure skipped',
104
+ );
105
+ },
106
+ );
107
+ }
108
+
109
+ function loadState(): void {
110
+ lastTimestamp = getRouterState('last_timestamp') || '';
111
+ const agentTs = getRouterState('last_agent_timestamp');
112
+ try {
113
+ lastAgentTimestamp = agentTs ? JSON.parse(agentTs) : {};
114
+ } catch {
115
+ logger.warn('Corrupted last_agent_timestamp in DB, resetting');
116
+ lastAgentTimestamp = {};
117
+ }
118
+ sessions = getAllSessions();
119
+ registeredGroups = getAllRegisteredGroups();
120
+ logger.info(
121
+ { groupCount: Object.keys(registeredGroups).length },
122
+ 'State loaded',
123
+ );
124
+ }
125
+
126
+ function saveState(): void {
127
+ setRouterState('last_timestamp', lastTimestamp);
128
+ setRouterState('last_agent_timestamp', JSON.stringify(lastAgentTimestamp));
129
+ }
130
+
131
+ function registerGroup(jid: string, group: RegisteredGroup): void {
132
+ let groupDir: string;
133
+ try {
134
+ groupDir = resolveGroupFolderPath(group.folder);
135
+ } catch (err) {
136
+ logger.warn(
137
+ { jid, folder: group.folder, err },
138
+ 'Rejecting group registration with invalid folder',
139
+ );
140
+ return;
141
+ }
142
+
143
+ registeredGroups[jid] = group;
144
+ setRegisteredGroup(jid, group);
145
+
146
+ // Create group folder
147
+ fs.mkdirSync(path.join(groupDir, 'logs'), { recursive: true });
148
+
149
+ // Copy CLAUDE.md template into the new group folder so agents have
150
+ // identity and instructions from the first run. (Fixes #1391)
151
+ const groupMdFile = path.join(groupDir, 'CLAUDE.md');
152
+ if (!fs.existsSync(groupMdFile)) {
153
+ const templateFile = path.join(
154
+ GROUPS_DIR,
155
+ group.isMain ? 'main' : 'global',
156
+ 'CLAUDE.md',
157
+ );
158
+ if (fs.existsSync(templateFile)) {
159
+ let content = fs.readFileSync(templateFile, 'utf-8');
160
+ if (ASSISTANT_NAME !== 'Andy') {
161
+ content = content.replace(/^# Andy$/m, `# ${ASSISTANT_NAME}`);
162
+ content = content.replace(/You are Andy/g, `You are ${ASSISTANT_NAME}`);
163
+ }
164
+ fs.writeFileSync(groupMdFile, content);
165
+ logger.info({ folder: group.folder }, 'Created CLAUDE.md from template');
166
+ }
167
+ }
168
+
169
+ // Ensure a corresponding OneCLI agent exists (best-effort, non-blocking)
170
+ ensureOneCLIAgent(jid, group);
171
+
172
+ logger.info(
173
+ { jid, name: group.name, folder: group.folder },
174
+ 'Group registered',
175
+ );
176
+ }
177
+
178
+ /**
179
+ * Get available groups list for the agent.
180
+ * Returns groups ordered by most recent activity.
181
+ */
182
+ export function getAvailableGroups(): import('./container-runner.js').AvailableGroup[] {
183
+ const chats = getAllChats();
184
+ const registeredJids = new Set(Object.keys(registeredGroups));
185
+
186
+ return chats
187
+ .filter((c) => c.jid !== '__group_sync__' && c.is_group)
188
+ .map((c) => ({
189
+ jid: c.jid,
190
+ name: c.name,
191
+ lastActivity: c.last_message_time,
192
+ isRegistered: registeredJids.has(c.jid),
193
+ }));
194
+ }
195
+
196
+ /** @internal - exported for testing */
197
+ export function _setRegisteredGroups(
198
+ groups: Record<string, RegisteredGroup>,
199
+ ): void {
200
+ registeredGroups = groups;
201
+ }
202
+
203
+ /**
204
+ * Process all pending messages for a group.
205
+ * Called by the GroupQueue when it's this group's turn.
206
+ */
207
+ async function processGroupMessages(chatJid: string): Promise<boolean> {
208
+ const group = registeredGroups[chatJid];
209
+ if (!group) return true;
210
+
211
+ const channel = findChannel(channels, chatJid);
212
+ if (!channel) {
213
+ logger.warn({ chatJid }, 'No channel owns JID, skipping messages');
214
+ return true;
215
+ }
216
+
217
+ const isMainGroup = group.isMain === true;
218
+
219
+ const sinceTimestamp = lastAgentTimestamp[chatJid] || '';
220
+ const missedMessages = getMessagesSince(
221
+ chatJid,
222
+ sinceTimestamp,
223
+ ASSISTANT_NAME,
224
+ );
225
+
226
+ if (missedMessages.length === 0) return true;
227
+
228
+ // For non-main groups, check if trigger is required and present
229
+ if (!isMainGroup && group.requiresTrigger !== false) {
230
+ const triggerPattern = getTriggerPattern(group.trigger);
231
+ const allowlistCfg = loadSenderAllowlist();
232
+ const hasTrigger = missedMessages.some(
233
+ (m) =>
234
+ triggerPattern.test(m.content.trim()) &&
235
+ (m.is_from_me || isTriggerAllowed(chatJid, m.sender, allowlistCfg)),
236
+ );
237
+ if (!hasTrigger) return true;
238
+ }
239
+
240
+ // Check STATUS.md — if the agent is disabled, send an offline reply and stop.
241
+ // isMain groups (Global Claw) are never subject to this check.
242
+ if (!isMainGroup) {
243
+ const statusFile = path.join(resolveGroupFolderPath(group.folder), 'STATUS.md');
244
+ if (fs.existsSync(statusFile)) {
245
+ const statusContent = fs.readFileSync(statusFile, 'utf-8').trim();
246
+ if (statusContent.toLowerCase().startsWith('disabled')) {
247
+ const reasonMatch = statusContent.match(/^Reason:\s*(.+)$/im);
248
+ const reason = reasonMatch ? reasonMatch[1].trim() : 'no reason given';
249
+ await channel.sendMessage(
250
+ chatJid,
251
+ `I'm currently offline. Reason: ${reason}`,
252
+ );
253
+ logger.info({ chatJid, group: group.name, reason }, 'Agent disabled — offline reply sent');
254
+ return true;
255
+ }
256
+ }
257
+ }
258
+
259
+ let prompt = formatMessages(missedMessages, TIMEZONE);
260
+
261
+ // Inject channel member list so agents can @mention users with <@UXXXX> format.
262
+ // Only includes members of this specific channel, not the whole workspace.
263
+ if (channel.getChannelMembers) {
264
+ const members = await channel.getChannelMembers(chatJid);
265
+ if (members.length > 0) {
266
+ const memberList = members
267
+ .map((m) => ` - ${m.name}: <@${m.id}>`)
268
+ .join('\n');
269
+ prompt += `\n\n<channel-members>\nTo @mention someone, use their mention tag exactly as shown:\n${memberList}\nOnly mention someone when they are directly relevant to the conversation. Do not mention people unnecessarily.\n</channel-members>`;
270
+ }
271
+ }
272
+
273
+ // Extract sender email from the last human message — used by agents that
274
+ // need to map the Slack user to an external system (e.g. Jira reporter).
275
+ const lastHumanMessage = [...missedMessages].reverse().find((m) => !m.is_from_me);
276
+ const senderEmail = lastHumanMessage?.sender_email;
277
+
278
+ // Advance cursor so the piping path in startMessageLoop won't re-fetch
279
+ // these messages. Save the old cursor so we can roll back on error.
280
+ const previousCursor = lastAgentTimestamp[chatJid] || '';
281
+ lastAgentTimestamp[chatJid] =
282
+ missedMessages[missedMessages.length - 1].timestamp;
283
+ saveState();
284
+
285
+ logger.info(
286
+ { group: group.name, messageCount: missedMessages.length },
287
+ 'Processing messages',
288
+ );
289
+
290
+ // Track idle timer for closing stdin when agent is idle
291
+ let idleTimer: ReturnType<typeof setTimeout> | null = null;
292
+
293
+ const resetIdleTimer = () => {
294
+ if (idleTimer) clearTimeout(idleTimer);
295
+ idleTimer = setTimeout(() => {
296
+ logger.debug(
297
+ { group: group.name },
298
+ 'Idle timeout, closing container stdin',
299
+ );
300
+ queue.closeStdin(chatJid);
301
+ }, IDLE_TIMEOUT);
302
+ };
303
+
304
+ await channel.setTyping?.(chatJid, true);
305
+ let hadError = false;
306
+ let outputSentToUser = false;
307
+
308
+ const output = await runAgent(group, prompt, chatJid, async (result) => {
309
+ // Streaming output callback — called for each agent result
310
+ if (result.result) {
311
+ const raw =
312
+ typeof result.result === 'string'
313
+ ? result.result
314
+ : JSON.stringify(result.result);
315
+ // Strip <internal>...</internal> blocks — agent uses these for internal reasoning
316
+ const text = raw.replace(/<internal>[\s\S]*?<\/internal>/g, '').trim();
317
+ logger.info({ group: group.name }, `Agent output: ${raw.length} chars`);
318
+ if (text) {
319
+ await channel.sendMessage(chatJid, text);
320
+ outputSentToUser = true;
321
+ }
322
+ // Only reset idle timer on actual results, not session-update markers (result: null)
323
+ resetIdleTimer();
324
+ }
325
+
326
+ if (result.status === 'success') {
327
+ queue.notifyIdle(chatJid);
328
+ }
329
+
330
+ if (result.status === 'error') {
331
+ hadError = true;
332
+ }
333
+ }, senderEmail);
334
+
335
+ await channel.setTyping?.(chatJid, false);
336
+ if (idleTimer) clearTimeout(idleTimer);
337
+
338
+ if (output === 'error' || hadError) {
339
+ // If we already sent output to the user, don't roll back the cursor —
340
+ // the user got their response and re-processing would send duplicates.
341
+ if (outputSentToUser) {
342
+ logger.warn(
343
+ { group: group.name },
344
+ 'Agent error after output was sent, skipping cursor rollback to prevent duplicates',
345
+ );
346
+ return true;
347
+ }
348
+ // Roll back cursor so retries can re-process these messages
349
+ lastAgentTimestamp[chatJid] = previousCursor;
350
+ saveState();
351
+ logger.warn(
352
+ { group: group.name },
353
+ 'Agent error, rolled back message cursor for retry',
354
+ );
355
+ return false;
356
+ }
357
+
358
+ return true;
359
+ }
360
+
361
+ async function runAgent(
362
+ group: RegisteredGroup,
363
+ prompt: string,
364
+ chatJid: string,
365
+ onOutput?: (output: ContainerOutput) => Promise<void>,
366
+ senderEmail?: string,
367
+ ): Promise<'success' | 'error'> {
368
+ const isMain = group.isMain === true;
369
+ const sessionId = sessions[group.folder];
370
+
371
+ // Update tasks snapshot for container to read (filtered by group)
372
+ const tasks = getAllTasks();
373
+ writeTasksSnapshot(
374
+ group.folder,
375
+ isMain,
376
+ tasks.map((t) => ({
377
+ id: t.id,
378
+ groupFolder: t.group_folder,
379
+ prompt: t.prompt,
380
+ script: t.script || undefined,
381
+ schedule_type: t.schedule_type,
382
+ schedule_value: t.schedule_value,
383
+ status: t.status,
384
+ next_run: t.next_run,
385
+ })),
386
+ );
387
+
388
+ // Update available groups snapshot (main group only can see all groups)
389
+ const availableGroups = getAvailableGroups();
390
+ writeGroupsSnapshot(
391
+ group.folder,
392
+ isMain,
393
+ availableGroups,
394
+ new Set(Object.keys(registeredGroups)),
395
+ );
396
+
397
+ // Wrap onOutput to track session ID from streamed results.
398
+ // Never persist newSessionId from an error result — the agent-runner echoes
399
+ // the incoming session ID in error payloads, which would re-save a broken session.
400
+ const wrappedOnOutput = onOutput
401
+ ? async (output: ContainerOutput) => {
402
+ if (output.newSessionId && output.status !== 'error') {
403
+ sessions[group.folder] = output.newSessionId;
404
+ setSession(group.folder, output.newSessionId);
405
+ }
406
+ await onOutput(output);
407
+ }
408
+ : undefined;
409
+
410
+ try {
411
+ const output = await runContainerAgent(
412
+ group,
413
+ {
414
+ prompt,
415
+ sessionId,
416
+ groupFolder: group.folder,
417
+ chatJid,
418
+ isMain,
419
+ assistantName: ASSISTANT_NAME,
420
+ senderEmail,
421
+ },
422
+ (proc, containerName) =>
423
+ queue.registerProcess(chatJid, proc, containerName, group.folder),
424
+ wrappedOnOutput,
425
+ );
426
+
427
+ if (output.status === 'error') {
428
+ // If the session file no longer exists, clear it so the next run starts fresh
429
+ // rather than re-saving the broken session ID and failing on every retry.
430
+ if (output.error?.includes('No conversation found with session ID')) {
431
+ delete sessions[group.folder];
432
+ clearSession(group.folder);
433
+ logger.warn(
434
+ { group: group.name, sessionId },
435
+ 'Session not found — cleared for fresh start',
436
+ );
437
+ }
438
+ logger.error(
439
+ { group: group.name, error: output.error },
440
+ 'Container agent error',
441
+ );
442
+ return 'error';
443
+ }
444
+
445
+ if (output.newSessionId) {
446
+ sessions[group.folder] = output.newSessionId;
447
+ setSession(group.folder, output.newSessionId);
448
+ }
449
+
450
+ return 'success';
451
+ } catch (err) {
452
+ logger.error({ group: group.name, err }, 'Agent error');
453
+ return 'error';
454
+ }
455
+ }
456
+
457
+ async function startMessageLoop(): Promise<void> {
458
+ if (messageLoopRunning) {
459
+ logger.debug('Message loop already running, skipping duplicate start');
460
+ return;
461
+ }
462
+ messageLoopRunning = true;
463
+
464
+ logger.info(`NanoClaw running (default trigger: ${DEFAULT_TRIGGER})`);
465
+
466
+ while (true) {
467
+ try {
468
+ const jids = Object.keys(registeredGroups);
469
+ const { messages, newTimestamp } = getNewMessages(
470
+ jids,
471
+ lastTimestamp,
472
+ ASSISTANT_NAME,
473
+ );
474
+
475
+ if (messages.length > 0) {
476
+ logger.info({ count: messages.length }, 'New messages');
477
+
478
+ // Advance the "seen" cursor for all messages immediately
479
+ lastTimestamp = newTimestamp;
480
+ saveState();
481
+
482
+ // Deduplicate by group
483
+ const messagesByGroup = new Map<string, NewMessage[]>();
484
+ for (const msg of messages) {
485
+ const existing = messagesByGroup.get(msg.chat_jid);
486
+ if (existing) {
487
+ existing.push(msg);
488
+ } else {
489
+ messagesByGroup.set(msg.chat_jid, [msg]);
490
+ }
491
+ }
492
+
493
+ for (const [chatJid, groupMessages] of messagesByGroup) {
494
+ const group = registeredGroups[chatJid];
495
+ if (!group) continue;
496
+
497
+ const channel = findChannel(channels, chatJid);
498
+ if (!channel) {
499
+ logger.warn({ chatJid }, 'No channel owns JID, skipping messages');
500
+ continue;
501
+ }
502
+
503
+ const isMainGroup = group.isMain === true;
504
+ const needsTrigger = !isMainGroup && group.requiresTrigger !== false;
505
+
506
+ // For non-main groups, only act on trigger messages.
507
+ // Non-trigger messages accumulate in DB and get pulled as
508
+ // context when a trigger eventually arrives.
509
+ if (needsTrigger) {
510
+ const triggerPattern = getTriggerPattern(group.trigger);
511
+ const allowlistCfg = loadSenderAllowlist();
512
+ const hasTrigger = groupMessages.some(
513
+ (m) =>
514
+ triggerPattern.test(m.content.trim()) &&
515
+ (m.is_from_me ||
516
+ isTriggerAllowed(chatJid, m.sender, allowlistCfg)),
517
+ );
518
+ if (!hasTrigger) continue;
519
+ }
520
+
521
+ // Pull all messages since lastAgentTimestamp so non-trigger
522
+ // context that accumulated between triggers is included.
523
+ const allPending = getMessagesSince(
524
+ chatJid,
525
+ lastAgentTimestamp[chatJid] || '',
526
+ ASSISTANT_NAME,
527
+ );
528
+ const messagesToSend =
529
+ allPending.length > 0 ? allPending : groupMessages;
530
+ const formatted = formatMessages(messagesToSend, TIMEZONE);
531
+
532
+ if (queue.sendMessage(chatJid, formatted)) {
533
+ logger.debug(
534
+ { chatJid, count: messagesToSend.length },
535
+ 'Piped messages to active container',
536
+ );
537
+ lastAgentTimestamp[chatJid] =
538
+ messagesToSend[messagesToSend.length - 1].timestamp;
539
+ saveState();
540
+ // Show typing indicator while the container processes the piped message
541
+ channel
542
+ .setTyping?.(chatJid, true)
543
+ ?.catch((err) =>
544
+ logger.warn({ chatJid, err }, 'Failed to set typing indicator'),
545
+ );
546
+ } else {
547
+ // No active container — enqueue for a new one
548
+ queue.enqueueMessageCheck(chatJid);
549
+ }
550
+ }
551
+ }
552
+ } catch (err) {
553
+ logger.error({ err }, 'Error in message loop');
554
+ }
555
+ await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL));
556
+ }
557
+ }
558
+
559
+ /**
560
+ * Startup recovery: check for unprocessed messages in registered groups.
561
+ * Handles crash between advancing lastTimestamp and processing messages.
562
+ */
563
+ function recoverPendingMessages(): void {
564
+ for (const [chatJid, group] of Object.entries(registeredGroups)) {
565
+ const sinceTimestamp = lastAgentTimestamp[chatJid] || '';
566
+ const pending = getMessagesSince(chatJid, sinceTimestamp, ASSISTANT_NAME);
567
+ if (pending.length > 0) {
568
+ logger.info(
569
+ { group: group.name, pendingCount: pending.length },
570
+ 'Recovery: found unprocessed messages',
571
+ );
572
+ queue.enqueueMessageCheck(chatJid);
573
+ }
574
+ }
575
+ }
576
+
577
+ function ensureContainerSystemRunning(): void {
578
+ ensureContainerRuntimeRunning();
579
+ cleanupOrphans();
580
+ cleanupStaleIptablesRules();
581
+ }
582
+
583
+ /**
584
+ * Auto-register agents from agents.yaml.
585
+ *
586
+ * On every startup, reads agents.yaml and registers any agent whose channel
587
+ * ID env var is set but isn't in the DB yet. This replaces the need to run
588
+ * setup-agents.sh manually on fresh installs.
589
+ */
590
+ /**
591
+ * Ensure every agent from agents.yaml has an entry in the sender allowlist.
592
+ *
593
+ * The allowlist file only needs entries for OVERRIDES — e.g. restricting
594
+ * global-claw to a specific sender. Every other registered agent is
595
+ * automatically allowed with the default open policy so agents.yaml + .env
596
+ * is the single file to maintain.
597
+ */
598
+ function ensureAllowlistEntries(agentDefs: Array<{ channel_env: string; requires_trigger?: boolean }>): void {
599
+ const allowlistPath = SENDER_ALLOWLIST_PATH;
600
+
601
+ // Read all channel env vars directly from .env (they are not in process.env —
602
+ // readEnvFile intentionally does not pollute the process environment)
603
+ const channelEnvKeys = agentDefs.map(d => d.channel_env);
604
+ const channelValues = readEnvFile(channelEnvKeys);
605
+
606
+ // Read current allowlist (or start with an empty chats map)
607
+ let config: { default?: unknown; chats: Record<string, unknown>; logDenied?: boolean };
608
+ try {
609
+ config = JSON.parse(fs.readFileSync(allowlistPath, 'utf-8'));
610
+ if (!config.chats || typeof config.chats !== 'object') config.chats = {};
611
+ } catch {
612
+ config = { chats: {} };
613
+ }
614
+
615
+ let changed = false;
616
+ for (const def of agentDefs) {
617
+ const channelId = channelValues[def.channel_env]?.trim();
618
+ if (!channelId) continue;
619
+ const jid = channelId.includes(':') ? channelId : `slack:${channelId}`;
620
+
621
+ if (!config.chats[jid]) {
622
+ config.chats[jid] = { allow: '*', mode: 'trigger' };
623
+ changed = true;
624
+ logger.debug({ jid }, 'Added allowlist entry for agent');
625
+ }
626
+ }
627
+
628
+ if (changed) {
629
+ try {
630
+ fs.mkdirSync(path.dirname(allowlistPath), { recursive: true });
631
+ fs.writeFileSync(allowlistPath, JSON.stringify(config, null, 2));
632
+ logger.info({ path: allowlistPath }, 'Sender allowlist updated with new agent channels');
633
+ } catch (err) {
634
+ logger.warn({ err, path: allowlistPath }, 'Could not update sender allowlist — add channel entries manually');
635
+ }
636
+ }
637
+ }
638
+
639
+ function autoRegisterAgentsFromYaml(): void {
640
+ const agentsFile = path.join(process.cwd(), 'agents.yaml');
641
+ if (!fs.existsSync(agentsFile)) return;
642
+
643
+ let agentDefs: Array<{
644
+ folder: string;
645
+ name: string;
646
+ trigger?: string;
647
+ channel_env: string;
648
+ requires_trigger?: boolean;
649
+ is_main?: boolean;
650
+ onecli_secrets?: string[];
651
+ onecli_id?: string;
652
+ }>;
653
+
654
+ try {
655
+ agentDefs = YAML.parse(fs.readFileSync(agentsFile, 'utf-8')).agents || [];
656
+ } catch (err) {
657
+ logger.warn({ err }, 'autoRegisterAgents: could not parse agents.yaml');
658
+ return;
659
+ }
660
+
661
+ // Read channel IDs from .env — they are intentionally NOT in process.env
662
+ const channelEnvKeys = agentDefs.map(d => d.channel_env);
663
+ const channelValues = readEnvFile(channelEnvKeys);
664
+
665
+ let added = 0;
666
+ for (const def of agentDefs) {
667
+ const channelId = channelValues[def.channel_env]?.trim();
668
+ if (!channelId) continue; // env var not set — skip
669
+
670
+ // Build JID — Slack channels start with C or D, HTTP channels are explicit
671
+ const jid = channelId.includes(':') ? channelId : `slack:${channelId}`;
672
+ if (registeredGroups[jid]) continue; // already registered
673
+
674
+ const group: RegisteredGroup = {
675
+ name: def.name,
676
+ folder: def.folder,
677
+ trigger: def.trigger || DEFAULT_TRIGGER,
678
+ added_at: new Date().toISOString(),
679
+ requiresTrigger: def.requires_trigger !== false,
680
+ isMain: def.is_main === true,
681
+ };
682
+
683
+ setRegisteredGroup(jid, group);
684
+ registeredGroups[jid] = group;
685
+
686
+ // Ensure the group folder and MEMORY.md exist
687
+ const groupDir = path.join(GROUPS_DIR, def.folder);
688
+ fs.mkdirSync(groupDir, { recursive: true });
689
+ const memoryFile = path.join(groupDir, 'MEMORY.md');
690
+ if (!fs.existsSync(memoryFile)) {
691
+ fs.writeFileSync(memoryFile, `# ${def.name} — Memory\n\nAgent initialized.\n`);
692
+ }
693
+
694
+ logger.info({ folder: def.folder, jid }, 'Auto-registered agent from agents.yaml');
695
+ added++;
696
+ }
697
+
698
+ // Ensure every registered agent has an entry in the sender allowlist.
699
+ // The allowlist file only needs to exist for OVERRIDES (e.g. restricting
700
+ // global-claw to specific senders). All other agents are auto-added with
701
+ // the default open policy so agents.yaml + .env is the only file to maintain.
702
+ ensureAllowlistEntries(agentDefs);
703
+
704
+ if (added > 0) {
705
+ logger.info({ count: added }, 'Auto-registered agents from agents.yaml');
706
+ }
707
+ }
708
+
709
+ /**
710
+ * Run setup-onecli-secrets.sh to ensure all credentials are registered.
711
+ *
712
+ * Uses a hash of the relevant .env values so the script only re-runs when
713
+ * credentials actually change — not on every startup.
714
+ */
715
+ function ensureOneCLISecrets(): void {
716
+ const secretsScript = path.join(process.cwd(), 'scripts', 'setup-onecli-secrets.sh');
717
+ if (!fs.existsSync(secretsScript)) return;
718
+
719
+ // Hash the credential-bearing env vars that the secrets script uses
720
+ const credentialKeys = [
721
+ 'ELASTIC_API_KEY', 'JIRA_EMAIL', 'JIRA_API_TOKEN',
722
+ 'SLACK_BOT_TOKEN', 'INTERCOM_ACCESS_TOKEN', 'ARDOQ_API_KEY',
723
+ 'ANTHROPIC_AUTH_TOKEN', 'CONFLUENCE_USERNAME', 'CONFLUENCE_PASSWORD',
724
+ ];
725
+ const hashInput = credentialKeys.map(k => `${k}=${process.env[k] || ''}`).join('\n');
726
+ const currentHash = crypto.createHash('sha256').update(hashInput).digest('hex');
727
+
728
+ const hashFile = path.join(STORE_DIR, 'onecli-secrets.hash');
729
+ const storedHash = fs.existsSync(hashFile) ? fs.readFileSync(hashFile, 'utf-8').trim() : '';
730
+
731
+ if (currentHash === storedHash) {
732
+ logger.debug('OneCLI secrets unchanged — skipping setup');
733
+ return;
734
+ }
735
+
736
+ logger.info('OneCLI credentials changed (or first run) — running secrets setup...');
737
+ try {
738
+ execSync(`bash "${secretsScript}"`, {
739
+ stdio: 'pipe',
740
+ timeout: 30000,
741
+ env: { ...process.env },
742
+ });
743
+ fs.writeFileSync(hashFile, currentHash);
744
+ logger.info('OneCLI secrets setup complete');
745
+ } catch (err) {
746
+ const error = err instanceof Error ? err.message : String(err);
747
+ logger.warn({ error }, 'OneCLI secrets setup failed — agents may lack credentials');
748
+ }
749
+ }
750
+
751
+ async function main(): Promise<void> {
752
+ ensureContainerSystemRunning();
753
+ initDatabase();
754
+ logger.info('Database initialized');
755
+ loadState();
756
+
757
+ // Auto-register any agents from agents.yaml whose channel env vars are set.
758
+ autoRegisterAgentsFromYaml();
759
+
760
+ // Ensure OneCLI secrets are up to date (re-runs only when credentials change).
761
+ ensureOneCLISecrets();
762
+
763
+ // Ensure OneCLI agents exist for all registered groups.
764
+ // Recovers from missed creates (e.g. OneCLI was down at registration time).
765
+ for (const [jid, group] of Object.entries(registeredGroups)) {
766
+ ensureOneCLIAgent(jid, group);
767
+ }
768
+
769
+ restoreRemoteControl();
770
+
771
+ // Graceful shutdown handlers
772
+ const shutdown = async (signal: string) => {
773
+ logger.info({ signal }, 'Shutdown signal received');
774
+ await queue.shutdown(10000);
775
+ for (const ch of channels) await ch.disconnect();
776
+ process.exit(0);
777
+ };
778
+ process.on('SIGTERM', () => shutdown('SIGTERM'));
779
+ process.on('SIGINT', () => shutdown('SIGINT'));
780
+
781
+ // Handle /remote-control and /remote-control-end commands
782
+ async function handleRemoteControl(
783
+ command: string,
784
+ chatJid: string,
785
+ msg: NewMessage,
786
+ ): Promise<void> {
787
+ const group = registeredGroups[chatJid];
788
+ if (!group?.isMain) {
789
+ logger.warn(
790
+ { chatJid, sender: msg.sender },
791
+ 'Remote control rejected: not main group',
792
+ );
793
+ return;
794
+ }
795
+
796
+ const channel = findChannel(channels, chatJid);
797
+ if (!channel) return;
798
+
799
+ if (command === '/remote-control') {
800
+ const result = await startRemoteControl(
801
+ msg.sender,
802
+ chatJid,
803
+ process.cwd(),
804
+ );
805
+ if (result.ok) {
806
+ await channel.sendMessage(chatJid, result.url);
807
+ } else {
808
+ await channel.sendMessage(
809
+ chatJid,
810
+ `Remote Control failed: ${result.error}`,
811
+ );
812
+ }
813
+ } else {
814
+ const result = stopRemoteControl();
815
+ if (result.ok) {
816
+ await channel.sendMessage(chatJid, 'Remote Control session ended.');
817
+ } else {
818
+ await channel.sendMessage(chatJid, result.error);
819
+ }
820
+ }
821
+ }
822
+
823
+ // Channel callbacks (shared by all channels)
824
+ const channelOpts = {
825
+ onMessage: (chatJid: string, msg: NewMessage) => {
826
+ // Remote control commands — intercept before storage
827
+ const trimmed = msg.content.trim();
828
+ if (trimmed === '/remote-control' || trimmed === '/remote-control-end') {
829
+ handleRemoteControl(trimmed, chatJid, msg).catch((err) =>
830
+ logger.error({ err, chatJid }, 'Remote control command error'),
831
+ );
832
+ return;
833
+ }
834
+
835
+ // Sender allowlist drop mode: discard messages from denied senders before storing
836
+ if (!msg.is_from_me && !msg.is_bot_message && registeredGroups[chatJid]) {
837
+ const cfg = loadSenderAllowlist();
838
+ if (
839
+ shouldDropMessage(chatJid, cfg) &&
840
+ !isSenderAllowed(chatJid, msg.sender, cfg)
841
+ ) {
842
+ if (cfg.logDenied) {
843
+ logger.debug(
844
+ { chatJid, sender: msg.sender },
845
+ 'sender-allowlist: dropping message (drop mode)',
846
+ );
847
+ }
848
+ return;
849
+ }
850
+ }
851
+ storeMessage(msg);
852
+ },
853
+ onChatMetadata: (
854
+ chatJid: string,
855
+ timestamp: string,
856
+ name?: string,
857
+ channel?: string,
858
+ isGroup?: boolean,
859
+ ) => storeChatMetadata(chatJid, timestamp, name, channel, isGroup),
860
+ registeredGroups: () => registeredGroups,
861
+ };
862
+
863
+ // Create and connect all registered channels.
864
+ // Each channel self-registers via the barrel import above.
865
+ // Factories return null when credentials are missing, so unconfigured channels are skipped.
866
+ for (const channelName of getRegisteredChannelNames()) {
867
+ const factory = getChannelFactory(channelName)!;
868
+ const channel = factory(channelOpts);
869
+ if (!channel) {
870
+ logger.warn(
871
+ { channel: channelName },
872
+ 'Channel installed but credentials missing — skipping. Check .env or re-run the channel skill.',
873
+ );
874
+ continue;
875
+ }
876
+ channels.push(channel);
877
+ await channel.connect();
878
+ }
879
+ if (channels.length === 0) {
880
+ logger.fatal('No channels connected');
881
+ process.exit(1);
882
+ }
883
+
884
+ // Start HTTP server for questionnaire API
885
+ startHttpServer({
886
+ registeredGroups: () => registeredGroups,
887
+ });
888
+
889
+ // Start subsystems (independently of connection handler)
890
+ startSchedulerLoop({
891
+ registeredGroups: () => registeredGroups,
892
+ getSessions: () => sessions,
893
+ queue,
894
+ onProcess: (groupJid, proc, containerName, groupFolder) =>
895
+ queue.registerProcess(groupJid, proc, containerName, groupFolder),
896
+ sendMessage: async (jid, rawText) => {
897
+ const channel = findChannel(channels, jid);
898
+ if (!channel) {
899
+ logger.warn({ jid }, 'No channel owns JID, cannot send message');
900
+ return;
901
+ }
902
+ const text = formatOutbound(rawText);
903
+ if (text) await channel.sendMessage(jid, text);
904
+ },
905
+ });
906
+ startIpcWatcher({
907
+ sendMessage: (jid, text) => {
908
+ const channel = findChannel(channels, jid);
909
+ if (!channel) throw new Error(`No channel for JID: ${jid}`);
910
+ return channel.sendMessage(jid, text);
911
+ },
912
+ registeredGroups: () => registeredGroups,
913
+ registerGroup,
914
+ syncGroups: async (force: boolean) => {
915
+ await Promise.all(
916
+ channels
917
+ .filter((ch) => ch.syncGroups)
918
+ .map((ch) => ch.syncGroups!(force)),
919
+ );
920
+ },
921
+ getAvailableGroups,
922
+ writeGroupsSnapshot: (gf, im, ag, rj) =>
923
+ writeGroupsSnapshot(gf, im, ag, rj),
924
+ onTasksChanged: () => {
925
+ const tasks = getAllTasks();
926
+ const taskRows = tasks.map((t) => ({
927
+ id: t.id,
928
+ groupFolder: t.group_folder,
929
+ prompt: t.prompt,
930
+ script: t.script || undefined,
931
+ schedule_type: t.schedule_type,
932
+ schedule_value: t.schedule_value,
933
+ status: t.status,
934
+ next_run: t.next_run,
935
+ }));
936
+ for (const group of Object.values(registeredGroups)) {
937
+ writeTasksSnapshot(group.folder, group.isMain === true, taskRows);
938
+ }
939
+ },
940
+ });
941
+ queue.setProcessMessagesFn(processGroupMessages);
942
+ recoverPendingMessages();
943
+ startMessageLoop().catch((err) => {
944
+ logger.fatal({ err }, 'Message loop crashed unexpectedly');
945
+ process.exit(1);
946
+ });
947
+ }
948
+
949
+ // Guard: only run when executed directly, not when imported by tests
950
+ const isDirectRun =
951
+ process.argv[1] &&
952
+ new URL(import.meta.url).pathname ===
953
+ new URL(`file://${process.argv[1]}`).pathname;
954
+
955
+ if (isDirectRun) {
956
+ main().catch((err) => {
957
+ logger.error({ err }, 'Failed to start NanoClaw');
958
+ process.exit(1);
959
+ });
960
+ }