@rozek/nanoclaw 0.0.5 → 0.0.6

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 (132) hide show
  1. package/container/agent-runner/package-lock.json +1524 -0
  2. package/dist/cli.js +39 -4
  3. package/dist/cli.js.map +1 -1
  4. package/dist/index.d.ts.map +1 -1
  5. package/dist/index.js +34 -0
  6. package/dist/index.js.map +1 -1
  7. package/package.json +7 -1
  8. package/.claude/settings.json +0 -1
  9. package/.claude/skills/add-compact/SKILL.md +0 -135
  10. package/.claude/skills/add-discord/SKILL.md +0 -203
  11. package/.claude/skills/add-gmail/SKILL.md +0 -220
  12. package/.claude/skills/add-image-vision/SKILL.md +0 -94
  13. package/.claude/skills/add-ollama-tool/SKILL.md +0 -153
  14. package/.claude/skills/add-parallel/SKILL.md +0 -290
  15. package/.claude/skills/add-pdf-reader/SKILL.md +0 -104
  16. package/.claude/skills/add-reactions/SKILL.md +0 -117
  17. package/.claude/skills/add-slack/SKILL.md +0 -207
  18. package/.claude/skills/add-telegram/SKILL.md +0 -222
  19. package/.claude/skills/add-telegram-swarm/SKILL.md +0 -384
  20. package/.claude/skills/add-voice-transcription/SKILL.md +0 -148
  21. package/.claude/skills/add-whatsapp/SKILL.md +0 -372
  22. package/.claude/skills/convert-to-apple-container/SKILL.md +0 -175
  23. package/.claude/skills/customize/SKILL.md +0 -110
  24. package/.claude/skills/debug/SKILL.md +0 -349
  25. package/.claude/skills/get-qodo-rules/SKILL.md +0 -122
  26. package/.claude/skills/get-qodo-rules/references/output-format.md +0 -41
  27. package/.claude/skills/get-qodo-rules/references/pagination.md +0 -33
  28. package/.claude/skills/get-qodo-rules/references/repository-scope.md +0 -26
  29. package/.claude/skills/qodo-pr-resolver/SKILL.md +0 -326
  30. package/.claude/skills/qodo-pr-resolver/resources/providers.md +0 -329
  31. package/.claude/skills/setup/SKILL.md +0 -218
  32. package/.claude/skills/update-nanoclaw/SKILL.md +0 -235
  33. package/.claude/skills/update-skills/SKILL.md +0 -130
  34. package/.claude/skills/use-local-whisper/SKILL.md +0 -152
  35. package/.claude/skills/x-integration/SKILL.md +0 -417
  36. package/.claude/skills/x-integration/agent.ts +0 -243
  37. package/.claude/skills/x-integration/host.ts +0 -159
  38. package/.claude/skills/x-integration/lib/browser.ts +0 -148
  39. package/.claude/skills/x-integration/lib/config.ts +0 -62
  40. package/.claude/skills/x-integration/scripts/like.ts +0 -56
  41. package/.claude/skills/x-integration/scripts/post.ts +0 -66
  42. package/.claude/skills/x-integration/scripts/quote.ts +0 -80
  43. package/.claude/skills/x-integration/scripts/reply.ts +0 -74
  44. package/.claude/skills/x-integration/scripts/retweet.ts +0 -62
  45. package/.claude/skills/x-integration/scripts/setup.ts +0 -87
  46. package/.env.example +0 -1
  47. package/.github/CODEOWNERS +0 -10
  48. package/.github/PULL_REQUEST_TEMPLATE.md +0 -14
  49. package/.github/workflows/bump-version.yml +0 -32
  50. package/.github/workflows/ci.yml +0 -25
  51. package/.github/workflows/merge-forward-skills.yml +0 -160
  52. package/.github/workflows/update-tokens.yml +0 -42
  53. package/.husky/pre-commit +0 -1
  54. package/.mcp.json +0 -3
  55. package/.nvmrc +0 -1
  56. package/.prettierrc +0 -3
  57. package/CHANGELOG.md +0 -8
  58. package/CONTRIBUTING.md +0 -23
  59. package/CONTRIBUTORS.md +0 -15
  60. package/NanoClaw_with_Web-Support.md +0 -326
  61. package/README_zh.md +0 -200
  62. package/assets/nanoclaw-favicon.png +0 -0
  63. package/assets/nanoclaw-icon.png +0 -0
  64. package/assets/nanoclaw-logo-dark.png +0 -0
  65. package/assets/nanoclaw-logo.png +0 -0
  66. package/assets/nanoclaw-profile.jpeg +0 -0
  67. package/assets/nanoclaw-sales.png +0 -0
  68. package/assets/social-preview.jpg +0 -0
  69. package/config-examples/mount-allowlist.json +0 -25
  70. package/docs/APPLE-CONTAINER-NETWORKING.md +0 -90
  71. package/docs/DEBUG_CHECKLIST.md +0 -143
  72. package/docs/REQUIREMENTS.md +0 -196
  73. package/docs/SDK_DEEP_DIVE.md +0 -643
  74. package/docs/SECURITY.md +0 -122
  75. package/docs/SPEC.md +0 -785
  76. package/docs/docker-sandboxes.md +0 -359
  77. package/docs/nanoclaw-architecture-final.md +0 -1063
  78. package/docs/nanorepo-architecture.md +0 -168
  79. package/docs/skills-as-branches.md +0 -662
  80. package/groups/global/CLAUDE.md +0 -58
  81. package/groups/main/CLAUDE.md +0 -246
  82. package/launchd/com.nanoclaw.plist +0 -32
  83. package/repo-tokens/README.md +0 -113
  84. package/repo-tokens/action.yml +0 -186
  85. package/repo-tokens/badge.svg +0 -23
  86. package/repo-tokens/examples/green.svg +0 -14
  87. package/repo-tokens/examples/red.svg +0 -14
  88. package/repo-tokens/examples/yellow-green.svg +0 -14
  89. package/repo-tokens/examples/yellow.svg +0 -14
  90. package/scripts/run-migrations.ts +0 -105
  91. package/setup.sh +0 -161
  92. package/src/channels/index.ts +0 -15
  93. package/src/channels/registry.test.ts +0 -42
  94. package/src/channels/registry.ts +0 -32
  95. package/src/channels/web.ts +0 -1931
  96. package/src/cli.ts +0 -254
  97. package/src/config.ts +0 -73
  98. package/src/container-runner.test.ts +0 -210
  99. package/src/container-runner.ts +0 -768
  100. package/src/container-runtime.test.ts +0 -149
  101. package/src/container-runtime.ts +0 -127
  102. package/src/credential-proxy.test.ts +0 -192
  103. package/src/credential-proxy.ts +0 -125
  104. package/src/db.test.ts +0 -484
  105. package/src/db.ts +0 -803
  106. package/src/env.ts +0 -42
  107. package/src/formatting.test.ts +0 -256
  108. package/src/group-folder.test.ts +0 -43
  109. package/src/group-folder.ts +0 -44
  110. package/src/group-queue.test.ts +0 -484
  111. package/src/group-queue.ts +0 -379
  112. package/src/index.ts +0 -854
  113. package/src/ipc-auth.test.ts +0 -679
  114. package/src/ipc.ts +0 -461
  115. package/src/logger.ts +0 -16
  116. package/src/mount-security.ts +0 -419
  117. package/src/remote-control.test.ts +0 -397
  118. package/src/remote-control.ts +0 -224
  119. package/src/router.ts +0 -52
  120. package/src/routing.test.ts +0 -170
  121. package/src/sender-allowlist.test.ts +0 -216
  122. package/src/sender-allowlist.ts +0 -128
  123. package/src/session-commands.test.ts +0 -247
  124. package/src/session-commands.ts +0 -163
  125. package/src/task-scheduler.test.ts +0 -129
  126. package/src/task-scheduler.ts +0 -328
  127. package/src/timezone.test.ts +0 -29
  128. package/src/timezone.ts +0 -16
  129. package/src/types.ts +0 -109
  130. package/tsconfig.json +0 -20
  131. package/vitest.config.ts +0 -7
  132. package/vitest.skills.config.ts +0 -7
package/src/index.ts DELETED
@@ -1,854 +0,0 @@
1
- import fs from 'fs';
2
- import path from 'path';
3
-
4
- import {
5
- ASSISTANT_NAME,
6
- CREDENTIAL_PROXY_PORT,
7
- DATA_DIR,
8
- GROUPS_DIR,
9
- IDLE_TIMEOUT,
10
- POLL_INTERVAL,
11
- STORE_DIR,
12
- TIMEZONE,
13
- TRIGGER_PATTERN,
14
- } from './config.js';
15
- import { startCredentialProxy } from './credential-proxy.js';
16
- import './channels/index.js';
17
- import {
18
- getChannelFactory,
19
- getRegisteredChannelNames,
20
- } from './channels/registry.js';
21
- import {
22
- ContainerOutput,
23
- runContainerAgent,
24
- writeGroupsSnapshot,
25
- writeTasksSnapshot,
26
- } from './container-runner.js';
27
- import {
28
- cleanupOrphans,
29
- ensureContainerRuntimeRunning,
30
- PROXY_BIND_HOST,
31
- } from './container-runtime.js';
32
- import {
33
- getAllChats,
34
- getAllRegisteredGroups,
35
- getAllSessions,
36
- getAllTasks,
37
- getMessagesSince,
38
- getNewMessages,
39
- getRegisteredGroup,
40
- getRouterState,
41
- initDatabase,
42
- setRegisteredGroup,
43
- setRouterState,
44
- setSession,
45
- storeChatMetadata,
46
- storeMessage,
47
- } from './db.js';
48
- import { GroupQueue } from './group-queue.js';
49
- import { resolveGroupFolderPath } from './group-folder.js';
50
- import { startIpcWatcher } from './ipc.js';
51
- import { findChannel, formatMessages, formatOutbound } from './router.js';
52
- import {
53
- restoreRemoteControl,
54
- startRemoteControl,
55
- stopRemoteControl,
56
- } from './remote-control.js';
57
- import {
58
- isSenderAllowed,
59
- isTriggerAllowed,
60
- loadSenderAllowlist,
61
- shouldDropMessage,
62
- } from './sender-allowlist.js';
63
- import {
64
- extractSessionCommand,
65
- handleSessionCommand,
66
- isSessionCommandAllowed,
67
- } from './session-commands.js';
68
- import { startSchedulerLoop } from './task-scheduler.js';
69
- import { Channel, NewMessage, RegisteredGroup } from './types.js';
70
- import { logger } from './logger.js';
71
-
72
- // Re-export for backwards compatibility during refactor
73
- export { escapeXml, formatMessages } from './router.js';
74
-
75
- let lastTimestamp = '';
76
- let sessions: Record<string, string> = {};
77
- let registeredGroups: Record<string, RegisteredGroup> = {};
78
- let lastAgentTimestamp: Record<string, string> = {};
79
- let messageLoopRunning = false;
80
-
81
- const channels: Channel[] = [];
82
- const queue = new GroupQueue();
83
-
84
- function loadState(): void {
85
- lastTimestamp = getRouterState('last_timestamp') || '';
86
- const agentTs = getRouterState('last_agent_timestamp');
87
- try {
88
- lastAgentTimestamp = agentTs ? JSON.parse(agentTs) : {};
89
- } catch {
90
- logger.warn('Corrupted last_agent_timestamp in DB, resetting');
91
- lastAgentTimestamp = {};
92
- }
93
- sessions = getAllSessions();
94
- registeredGroups = getAllRegisteredGroups();
95
- logger.info(
96
- { groupCount: Object.keys(registeredGroups).length },
97
- 'State loaded',
98
- );
99
- }
100
-
101
- function saveState(): void {
102
- setRouterState('last_timestamp', lastTimestamp);
103
- setRouterState('last_agent_timestamp', JSON.stringify(lastAgentTimestamp));
104
- }
105
-
106
- function registerGroup(jid: string, group: RegisteredGroup): void {
107
- let groupDir: string;
108
- try {
109
- groupDir = resolveGroupFolderPath(group.folder);
110
- } catch (err) {
111
- logger.warn(
112
- { jid, folder: group.folder, err },
113
- 'Rejecting group registration with invalid folder',
114
- );
115
- return;
116
- }
117
-
118
- registeredGroups[jid] = group;
119
- setRegisteredGroup(jid, group);
120
-
121
- // Create group folder
122
- fs.mkdirSync(path.join(groupDir, 'logs'), { recursive: true });
123
-
124
- logger.info(
125
- { jid, name: group.name, folder: group.folder },
126
- 'Group registered',
127
- );
128
- }
129
-
130
- /**
131
- * Get available groups list for the agent.
132
- * Returns groups ordered by most recent activity.
133
- */
134
- export function getAvailableGroups(): import('./container-runner.js').AvailableGroup[] {
135
- const chats = getAllChats();
136
- const registeredJids = new Set(Object.keys(registeredGroups));
137
-
138
- return chats
139
- .filter((c) => c.jid !== '__group_sync__' && c.is_group)
140
- .map((c) => ({
141
- jid: c.jid,
142
- name: c.name,
143
- lastActivity: c.last_message_time,
144
- isRegistered: registeredJids.has(c.jid),
145
- }));
146
- }
147
-
148
- /** @internal - exported for testing */
149
- export function _setRegisteredGroups(
150
- groups: Record<string, RegisteredGroup>,
151
- ): void {
152
- registeredGroups = groups;
153
- }
154
-
155
- /**
156
- * Classify an Anthropic SDK error string and return a user-facing message
157
- * plus whether the error is permanent (no retry) or transient (retry).
158
- * Returns null if the error is not a recognized API error.
159
- */
160
- function formatApiError(
161
- error: string,
162
- ): { message: string; permanent: boolean } | null {
163
- if (
164
- /401|unauthorized|invalid.*key|expired.*key|authentication/i.test(error)
165
- ) {
166
- return {
167
- message:
168
- '⚠️ Anthropic-Anmeldung fehlgeschlagen (401). Bitte prüfe die API-Konfiguration.',
169
- permanent: true,
170
- };
171
- }
172
- if (/429|rate.?limit/i.test(error)) {
173
- return {
174
- message:
175
- '⚠️ Anthropic-Anfragelimit erreicht (429). Ich versuche es in Kürze erneut.',
176
- permanent: false,
177
- };
178
- }
179
- if (/529|503|overload|unavailable/i.test(error)) {
180
- return {
181
- message:
182
- '⚠️ Anthropic ist gerade überlastet. Ich versuche es in Kürze erneut.',
183
- permanent: false,
184
- };
185
- }
186
- if (/\b500\b|internal.server.error/i.test(error)) {
187
- return {
188
- message:
189
- '⚠️ Anthropic-Serverfehler (500). Ich versuche es in Kürze erneut.',
190
- permanent: false,
191
- };
192
- }
193
- return null;
194
- }
195
-
196
- /**
197
- * Process all pending messages for a group.
198
- * Called by the GroupQueue when it's this group's turn.
199
- */
200
- async function processGroupMessages(chatJid: string): Promise<boolean> {
201
- const group = registeredGroups[chatJid];
202
- if (!group) return true;
203
-
204
- const channel = findChannel(channels, chatJid);
205
- if (!channel) {
206
- logger.warn({ chatJid }, 'No channel owns JID, skipping messages');
207
- return true;
208
- }
209
-
210
- const isMainGroup = group.isMain === true;
211
-
212
- const sinceTimestamp = lastAgentTimestamp[chatJid] || '';
213
- const missedMessages = getMessagesSince(
214
- chatJid,
215
- sinceTimestamp,
216
- ASSISTANT_NAME,
217
- );
218
-
219
- if (missedMessages.length === 0) return true;
220
-
221
- // --- Session command interception (before trigger check) ---
222
- const cmdResult = await handleSessionCommand({
223
- missedMessages,
224
- isMainGroup,
225
- groupName: group.name,
226
- triggerPattern: TRIGGER_PATTERN,
227
- timezone: TIMEZONE,
228
- deps: {
229
- sendMessage: (text) => channel.sendMessage(chatJid, text),
230
- setTyping: (typing) =>
231
- channel.setTyping?.(chatJid, typing) ?? Promise.resolve(),
232
- runAgent: (prompt, onOutput) =>
233
- runAgent(group, prompt, chatJid, onOutput),
234
- closeStdin: () => queue.closeStdin(chatJid),
235
- advanceCursor: (ts) => {
236
- lastAgentTimestamp[chatJid] = ts;
237
- saveState();
238
- },
239
- formatMessages,
240
- canSenderInteract: (msg) => {
241
- const hasTrigger = TRIGGER_PATTERN.test(msg.content.trim());
242
- const reqTrigger = !isMainGroup && group.requiresTrigger !== false;
243
- return (
244
- isMainGroup ||
245
- !reqTrigger ||
246
- (hasTrigger &&
247
- (msg.is_from_me ||
248
- isTriggerAllowed(chatJid, msg.sender, loadSenderAllowlist())))
249
- );
250
- },
251
- },
252
- });
253
- if (cmdResult.handled) return cmdResult.success;
254
- // --- End session command interception ---
255
-
256
- // For non-main groups, check if trigger is required and present
257
- if (!isMainGroup && group.requiresTrigger !== false) {
258
- const allowlistCfg = loadSenderAllowlist();
259
- const hasTrigger = missedMessages.some(
260
- (m) =>
261
- TRIGGER_PATTERN.test(m.content.trim()) &&
262
- (m.is_from_me || isTriggerAllowed(chatJid, m.sender, allowlistCfg)),
263
- );
264
- if (!hasTrigger) return true;
265
- }
266
-
267
- const prompt = formatMessages(missedMessages, TIMEZONE);
268
-
269
- // Advance cursor so the piping path in startMessageLoop won't re-fetch
270
- // these messages. Save the old cursor so we can roll back on error.
271
- const previousCursor = lastAgentTimestamp[chatJid] || '';
272
- lastAgentTimestamp[chatJid] =
273
- missedMessages[missedMessages.length - 1].timestamp;
274
- saveState();
275
-
276
- logger.info(
277
- { group: group.name, messageCount: missedMessages.length },
278
- 'Processing messages',
279
- );
280
-
281
- // Track idle timer for closing stdin when agent is idle
282
- let idleTimer: ReturnType<typeof setTimeout> | null = null;
283
-
284
- const resetIdleTimer = () => {
285
- if (idleTimer) clearTimeout(idleTimer);
286
- idleTimer = setTimeout(() => {
287
- logger.debug(
288
- { group: group.name },
289
- 'Idle timeout, closing container stdin',
290
- );
291
- queue.closeStdin(chatJid);
292
- }, IDLE_TIMEOUT);
293
- };
294
-
295
- await channel.setTyping?.(chatJid, true);
296
- let hadError = false;
297
- let outputSentToUser = false;
298
-
299
- // Status callback: broadcasts live tool-use events to the channel (best-effort)
300
- const statusCallback = (tool: string, inputSnippet?: string) => {
301
- try {
302
- channel.setStatus?.(chatJid, tool, inputSnippet);
303
- } catch {
304
- /* non-critical */
305
- }
306
- };
307
-
308
- const output = await runAgent(
309
- group,
310
- prompt,
311
- chatJid,
312
- async (result) => {
313
- // Streaming output callback — called for each agent result
314
- if (result.result) {
315
- const raw =
316
- typeof result.result === 'string'
317
- ? result.result
318
- : JSON.stringify(result.result);
319
- // Strip <internal>...</internal> blocks — agent uses these for internal reasoning
320
- const text = raw.replace(/<internal>[\s\S]*?<\/internal>/g, '').trim();
321
- logger.info(
322
- { group: group.name },
323
- `Agent output: ${raw.slice(0, 200)}`,
324
- );
325
- if (text) {
326
- await channel.sendMessage(chatJid, text);
327
- outputSentToUser = true;
328
- }
329
- // Only reset idle timer on actual results, not session-update markers (result: null)
330
- resetIdleTimer();
331
- }
332
-
333
- if (result.status === 'success') {
334
- queue.notifyIdle(chatJid);
335
- }
336
-
337
- if (result.status === 'error') {
338
- hadError = true;
339
- if (result.error && !outputSentToUser) {
340
- const apiError = formatApiError(result.error);
341
- if (apiError) {
342
- logger.info(
343
- { group: group.name, permanent: apiError.permanent },
344
- 'Anthropic API error detected, notifying user',
345
- );
346
- try {
347
- await channel.sendMessage(chatJid, apiError.message);
348
- // For permanent errors (e.g. 401), mark as "output sent" to prevent
349
- // cursor rollback and infinite retry. For transient errors (429/529/500),
350
- // leave outputSentToUser=false so the cursor rolls back and retries.
351
- if (apiError.permanent) {
352
- outputSentToUser = true;
353
- }
354
- } catch {
355
- /* ignore send failure */
356
- }
357
- }
358
- }
359
- }
360
- },
361
- statusCallback,
362
- );
363
-
364
- // Clear status display when the agent is done
365
- try {
366
- channel.setStatus?.(chatJid, null);
367
- } catch {
368
- /* non-critical */
369
- }
370
-
371
- await channel.setTyping?.(chatJid, false);
372
- if (idleTimer) clearTimeout(idleTimer);
373
-
374
- if (output === 'error' || hadError) {
375
- // If we already sent output to the user, don't roll back the cursor —
376
- // the user got their response and re-processing would send duplicates.
377
- if (outputSentToUser) {
378
- logger.warn(
379
- { group: group.name },
380
- 'Agent error after output was sent, skipping cursor rollback to prevent duplicates',
381
- );
382
- return true;
383
- }
384
- // Roll back cursor so retries can re-process these messages
385
- lastAgentTimestamp[chatJid] = previousCursor;
386
- saveState();
387
- logger.warn(
388
- { group: group.name },
389
- 'Agent error, rolled back message cursor for retry',
390
- );
391
- return false;
392
- }
393
-
394
- return true;
395
- }
396
-
397
- async function runAgent(
398
- group: RegisteredGroup,
399
- prompt: string,
400
- chatJid: string,
401
- onOutput?: (output: ContainerOutput) => Promise<void>,
402
- onStatus?: (tool: string, inputSnippet?: string) => void,
403
- ): Promise<'success' | 'error'> {
404
- const isMain = group.isMain === true;
405
- // Use chatJid as session key so each web session gets its own Claude conversation
406
- // context. Fall back to group.folder for backward compatibility with stored sessions.
407
- const sessionId = sessions[chatJid] ?? sessions[group.folder];
408
-
409
- // Update tasks snapshot for container to read (filtered by group)
410
- const tasks = getAllTasks();
411
- writeTasksSnapshot(
412
- group.folder,
413
- isMain,
414
- tasks.map((t) => ({
415
- id: t.id,
416
- groupFolder: t.group_folder,
417
- prompt: t.prompt,
418
- schedule_type: t.schedule_type,
419
- schedule_value: t.schedule_value,
420
- status: t.status,
421
- next_run: t.next_run,
422
- })),
423
- );
424
-
425
- // Update available groups snapshot (main group only can see all groups)
426
- const availableGroups = getAvailableGroups();
427
- writeGroupsSnapshot(
428
- group.folder,
429
- isMain,
430
- availableGroups,
431
- new Set(Object.keys(registeredGroups)),
432
- );
433
-
434
- // Wrap onOutput to track session ID from streamed results
435
- const wrappedOnOutput = onOutput
436
- ? async (output: ContainerOutput) => {
437
- if (output.newSessionId) {
438
- sessions[chatJid] = output.newSessionId;
439
- setSession(chatJid, output.newSessionId);
440
- }
441
- await onOutput(output);
442
- }
443
- : undefined;
444
-
445
- try {
446
- const output = await runContainerAgent(
447
- group,
448
- {
449
- prompt,
450
- sessionId,
451
- groupFolder: group.folder,
452
- chatJid,
453
- isMain,
454
- assistantName: ASSISTANT_NAME,
455
- },
456
- (proc, containerName) =>
457
- queue.registerProcess(chatJid, proc, containerName, group.folder),
458
- wrappedOnOutput,
459
- onStatus,
460
- );
461
-
462
- if (output.newSessionId) {
463
- sessions[chatJid] = output.newSessionId;
464
- setSession(chatJid, output.newSessionId);
465
- }
466
-
467
- if (output.status === 'error') {
468
- logger.error(
469
- { group: group.name, error: output.error },
470
- 'Container agent error',
471
- );
472
- return 'error';
473
- }
474
-
475
- return 'success';
476
- } catch (err) {
477
- logger.error({ group: group.name, err }, 'Agent error');
478
- return 'error';
479
- }
480
- }
481
-
482
- async function startMessageLoop(): Promise<void> {
483
- if (messageLoopRunning) {
484
- logger.debug('Message loop already running, skipping duplicate start');
485
- return;
486
- }
487
- messageLoopRunning = true;
488
-
489
- logger.info(`NanoClaw running (trigger: @${ASSISTANT_NAME})`);
490
-
491
- while (true) {
492
- try {
493
- const jids = Object.keys(registeredGroups);
494
- const { messages, newTimestamp } = getNewMessages(
495
- jids,
496
- lastTimestamp,
497
- ASSISTANT_NAME,
498
- );
499
-
500
- if (messages.length > 0) {
501
- logger.info({ count: messages.length }, 'New messages');
502
-
503
- // Advance the "seen" cursor for all messages immediately
504
- lastTimestamp = newTimestamp;
505
- saveState();
506
-
507
- // Deduplicate by group
508
- const messagesByGroup = new Map<string, NewMessage[]>();
509
- for (const msg of messages) {
510
- const existing = messagesByGroup.get(msg.chat_jid);
511
- if (existing) {
512
- existing.push(msg);
513
- } else {
514
- messagesByGroup.set(msg.chat_jid, [msg]);
515
- }
516
- }
517
-
518
- for (const [chatJid, groupMessages] of messagesByGroup) {
519
- const group = registeredGroups[chatJid];
520
- if (!group) continue;
521
-
522
- const channel = findChannel(channels, chatJid);
523
- if (!channel) {
524
- logger.warn({ chatJid }, 'No channel owns JID, skipping messages');
525
- continue;
526
- }
527
-
528
- const isMainGroup = group.isMain === true;
529
-
530
- // --- Session command interception (message loop) ---
531
- // Scan ALL messages in the batch for a session command.
532
- const loopCmdMsg = groupMessages.find(
533
- (m) => extractSessionCommand(m.content, TRIGGER_PATTERN) !== null,
534
- );
535
-
536
- if (loopCmdMsg) {
537
- // Only close active container if the sender is authorized — otherwise an
538
- // untrusted user could kill in-flight work by sending /compact (DoS).
539
- // closeStdin no-ops internally when no container is active.
540
- if (
541
- isSessionCommandAllowed(
542
- isMainGroup,
543
- loopCmdMsg.is_from_me === true,
544
- )
545
- ) {
546
- queue.closeStdin(chatJid);
547
- }
548
- // Enqueue so processGroupMessages handles auth + cursor advancement.
549
- // Don't pipe via IPC — slash commands need a fresh container with
550
- // string prompt (not MessageStream) for SDK recognition.
551
- queue.enqueueMessageCheck(chatJid);
552
- continue;
553
- }
554
- // --- End session command interception ---
555
-
556
- const needsTrigger = !isMainGroup && group.requiresTrigger !== false;
557
-
558
- // For non-main groups, only act on trigger messages.
559
- // Non-trigger messages accumulate in DB and get pulled as
560
- // context when a trigger eventually arrives.
561
- if (needsTrigger) {
562
- const allowlistCfg = loadSenderAllowlist();
563
- const hasTrigger = groupMessages.some(
564
- (m) =>
565
- TRIGGER_PATTERN.test(m.content.trim()) &&
566
- (m.is_from_me ||
567
- isTriggerAllowed(chatJid, m.sender, allowlistCfg)),
568
- );
569
- if (!hasTrigger) continue;
570
- }
571
-
572
- // Pull all messages since lastAgentTimestamp so non-trigger
573
- // context that accumulated between triggers is included.
574
- const allPending = getMessagesSince(
575
- chatJid,
576
- lastAgentTimestamp[chatJid] || '',
577
- ASSISTANT_NAME,
578
- );
579
- const messagesToSend =
580
- allPending.length > 0 ? allPending : groupMessages;
581
- const formatted = formatMessages(messagesToSend, TIMEZONE);
582
-
583
- if (queue.sendMessage(chatJid, formatted)) {
584
- logger.debug(
585
- { chatJid, count: messagesToSend.length },
586
- 'Piped messages to active container',
587
- );
588
- lastAgentTimestamp[chatJid] =
589
- messagesToSend[messagesToSend.length - 1].timestamp;
590
- saveState();
591
- // Show typing indicator while the container processes the piped message
592
- channel
593
- .setTyping?.(chatJid, true)
594
- ?.catch((err) =>
595
- logger.warn({ chatJid, err }, 'Failed to set typing indicator'),
596
- );
597
- } else {
598
- // No active container — enqueue for a new one
599
- queue.enqueueMessageCheck(chatJid);
600
- }
601
- }
602
- }
603
- } catch (err) {
604
- logger.error({ err }, 'Error in message loop');
605
- }
606
- await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL));
607
- }
608
- }
609
-
610
- /**
611
- * Startup recovery: check for unprocessed messages in registered groups.
612
- * Handles crash between advancing lastTimestamp and processing messages.
613
- */
614
- function recoverPendingMessages(): void {
615
- for (const [chatJid, group] of Object.entries(registeredGroups)) {
616
- const sinceTimestamp = lastAgentTimestamp[chatJid] || '';
617
- const pending = getMessagesSince(chatJid, sinceTimestamp, ASSISTANT_NAME);
618
- if (pending.length > 0) {
619
- logger.info(
620
- { group: group.name, pendingCount: pending.length },
621
- 'Recovery: found unprocessed messages',
622
- );
623
- queue.enqueueMessageCheck(chatJid);
624
- }
625
- }
626
- }
627
-
628
- function ensureContainerSystemRunning(): void {
629
- ensureContainerRuntimeRunning();
630
- cleanupOrphans();
631
- }
632
-
633
- /** Export for use by cli.ts entry point. */
634
- function initWorkspace(): void {
635
- const dirs = [
636
- STORE_DIR,
637
- GROUPS_DIR,
638
- path.join(GROUPS_DIR, 'main'),
639
- path.join(GROUPS_DIR, 'global'),
640
- path.join(DATA_DIR, 'ipc'),
641
- path.join(DATA_DIR, 'sessions'),
642
- path.join(GROUPS_DIR, 'main', 'Tools'),
643
- path.join(GROUPS_DIR, 'main', 'Skills'),
644
- path.join(GROUPS_DIR, 'main', 'MCP-Servers'),
645
- path.join(GROUPS_DIR, 'main', 'conversations'),
646
- ];
647
- for (const dir of dirs) {
648
- fs.mkdirSync(dir, { recursive: true });
649
- }
650
- }
651
-
652
- export async function main(): Promise<void> {
653
- initWorkspace();
654
- ensureContainerSystemRunning();
655
- initDatabase();
656
- logger.info('Database initialized');
657
- loadState();
658
- restoreRemoteControl();
659
-
660
- // Start credential proxy (containers route API calls through this)
661
- const proxyServer = await startCredentialProxy(
662
- CREDENTIAL_PROXY_PORT,
663
- PROXY_BIND_HOST,
664
- );
665
-
666
- // Graceful shutdown handlers
667
- const shutdown = async (signal: string) => {
668
- logger.info({ signal }, 'Shutdown signal received');
669
- proxyServer.close();
670
- await queue.shutdown(10000);
671
- for (const ch of channels) await ch.disconnect();
672
- process.exit(0);
673
- };
674
- process.on('SIGTERM', () => shutdown('SIGTERM'));
675
- process.on('SIGINT', () => shutdown('SIGINT'));
676
-
677
- // Handle /remote-control and /remote-control-end commands
678
- async function handleRemoteControl(
679
- command: string,
680
- chatJid: string,
681
- msg: NewMessage,
682
- ): Promise<void> {
683
- const group = registeredGroups[chatJid];
684
- if (!group?.isMain) {
685
- logger.warn(
686
- { chatJid, sender: msg.sender },
687
- 'Remote control rejected: not main group',
688
- );
689
- return;
690
- }
691
-
692
- const channel = findChannel(channels, chatJid);
693
- if (!channel) return;
694
-
695
- if (command === '/remote-control') {
696
- const result = await startRemoteControl(
697
- msg.sender,
698
- chatJid,
699
- process.cwd(),
700
- );
701
- if (result.ok) {
702
- await channel.sendMessage(chatJid, result.url);
703
- } else {
704
- await channel.sendMessage(
705
- chatJid,
706
- `Remote Control failed: ${result.error}`,
707
- );
708
- }
709
- } else {
710
- const result = stopRemoteControl();
711
- if (result.ok) {
712
- await channel.sendMessage(chatJid, 'Remote Control session ended.');
713
- } else {
714
- await channel.sendMessage(chatJid, result.error);
715
- }
716
- }
717
- }
718
-
719
- // Channel callbacks (shared by all channels)
720
- const channelOpts = {
721
- onMessage: (chatJid: string, msg: NewMessage) => {
722
- // Remote control commands — intercept before storage
723
- const trimmed = msg.content.trim();
724
- if (trimmed === '/remote-control' || trimmed === '/remote-control-end') {
725
- handleRemoteControl(trimmed, chatJid, msg).catch((err) =>
726
- logger.error({ err, chatJid }, 'Remote control command error'),
727
- );
728
- return;
729
- }
730
-
731
- // Sender allowlist drop mode: discard messages from denied senders before storing
732
- if (!msg.is_from_me && !msg.is_bot_message && registeredGroups[chatJid]) {
733
- const cfg = loadSenderAllowlist();
734
- if (
735
- shouldDropMessage(chatJid, cfg) &&
736
- !isSenderAllowed(chatJid, msg.sender, cfg)
737
- ) {
738
- if (cfg.logDenied) {
739
- logger.debug(
740
- { chatJid, sender: msg.sender },
741
- 'sender-allowlist: dropping message (drop mode)',
742
- );
743
- }
744
- return;
745
- }
746
- }
747
- storeMessage(msg);
748
- },
749
- onChatMetadata: (
750
- chatJid: string,
751
- timestamp: string,
752
- name?: string,
753
- channel?: string,
754
- isGroup?: boolean,
755
- ) => storeChatMetadata(chatJid, timestamp, name, channel, isGroup),
756
- registeredGroups: () => registeredGroups,
757
- onCancelRequest: (jid: string) => {
758
- logger.info({ jid }, 'Cancel request received, killing container');
759
- queue.cancelContainer(jid);
760
- },
761
- };
762
-
763
- // Create and connect all registered channels.
764
- // Each channel self-registers via the barrel import above.
765
- // Factories return null when credentials are missing, so unconfigured channels are skipped.
766
- for (const channelName of getRegisteredChannelNames()) {
767
- const factory = getChannelFactory(channelName)!;
768
- const channel = factory(channelOpts);
769
- if (!channel) {
770
- logger.warn(
771
- { channel: channelName },
772
- 'Channel installed but credentials missing — skipping. Check .env or re-run the channel skill.',
773
- );
774
- continue;
775
- }
776
- channels.push(channel);
777
- await channel.connect();
778
- }
779
- if (channels.length === 0) {
780
- logger.fatal('No channels connected');
781
- process.exit(1);
782
- }
783
-
784
- // Start subsystems (independently of connection handler)
785
- startSchedulerLoop({
786
- registeredGroups: () => registeredGroups,
787
- getSessions: () => sessions,
788
- queue,
789
- onProcess: (groupJid, proc, containerName, groupFolder) =>
790
- queue.registerProcess(groupJid, proc, containerName, groupFolder),
791
- sendMessage: async (jid, rawText) => {
792
- const channel = findChannel(channels, jid);
793
- if (!channel) {
794
- logger.warn({ jid }, 'No channel owns JID, cannot send message');
795
- return;
796
- }
797
- const text = formatOutbound(rawText);
798
- if (text) await channel.sendMessage(jid, text);
799
- },
800
- });
801
- startIpcWatcher({
802
- sendMessage: (jid, text) => {
803
- const channel = findChannel(channels, jid);
804
- if (!channel) throw new Error(`No channel for JID: ${jid}`);
805
- return channel.sendMessage(jid, text);
806
- },
807
- registeredGroups: () => registeredGroups,
808
- registerGroup,
809
- syncGroups: async (force: boolean) => {
810
- await Promise.all(
811
- channels
812
- .filter((ch) => ch.syncGroups)
813
- .map((ch) => ch.syncGroups!(force)),
814
- );
815
- },
816
- getAvailableGroups,
817
- writeGroupsSnapshot: (gf, im, ag, rj) =>
818
- writeGroupsSnapshot(gf, im, ag, rj),
819
- onTasksChanged: () => {
820
- const tasks = getAllTasks();
821
- const taskRows = tasks.map((t) => ({
822
- id: t.id,
823
- groupFolder: t.group_folder,
824
- prompt: t.prompt,
825
- schedule_type: t.schedule_type,
826
- schedule_value: t.schedule_value,
827
- status: t.status,
828
- next_run: t.next_run,
829
- }));
830
- for (const group of Object.values(registeredGroups)) {
831
- writeTasksSnapshot(group.folder, group.isMain === true, taskRows);
832
- }
833
- },
834
- });
835
- queue.setProcessMessagesFn(processGroupMessages);
836
- recoverPendingMessages();
837
- startMessageLoop().catch((err) => {
838
- logger.fatal({ err }, 'Message loop crashed unexpectedly');
839
- process.exit(1);
840
- });
841
- }
842
-
843
- // Guard: only run when executed directly, not when imported by tests
844
- const isDirectRun =
845
- process.argv[1] &&
846
- new URL(import.meta.url).pathname ===
847
- new URL(`file://${process.argv[1]}`).pathname;
848
-
849
- if (isDirectRun) {
850
- main().catch((err) => {
851
- logger.error({ err }, 'Failed to start NanoClaw');
852
- process.exit(1);
853
- });
854
- }