@rozek/nanoclaw 0.0.1

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 (306) hide show
  1. package/.claude/settings.json +1 -0
  2. package/.claude/skills/add-compact/SKILL.md +135 -0
  3. package/.claude/skills/add-discord/SKILL.md +203 -0
  4. package/.claude/skills/add-gmail/SKILL.md +220 -0
  5. package/.claude/skills/add-image-vision/SKILL.md +94 -0
  6. package/.claude/skills/add-ollama-tool/SKILL.md +153 -0
  7. package/.claude/skills/add-parallel/SKILL.md +290 -0
  8. package/.claude/skills/add-pdf-reader/SKILL.md +104 -0
  9. package/.claude/skills/add-reactions/SKILL.md +117 -0
  10. package/.claude/skills/add-slack/SKILL.md +207 -0
  11. package/.claude/skills/add-telegram/SKILL.md +222 -0
  12. package/.claude/skills/add-telegram-swarm/SKILL.md +384 -0
  13. package/.claude/skills/add-voice-transcription/SKILL.md +148 -0
  14. package/.claude/skills/add-whatsapp/SKILL.md +372 -0
  15. package/.claude/skills/convert-to-apple-container/SKILL.md +175 -0
  16. package/.claude/skills/customize/SKILL.md +110 -0
  17. package/.claude/skills/debug/SKILL.md +349 -0
  18. package/.claude/skills/get-qodo-rules/SKILL.md +122 -0
  19. package/.claude/skills/get-qodo-rules/references/output-format.md +41 -0
  20. package/.claude/skills/get-qodo-rules/references/pagination.md +33 -0
  21. package/.claude/skills/get-qodo-rules/references/repository-scope.md +26 -0
  22. package/.claude/skills/qodo-pr-resolver/SKILL.md +326 -0
  23. package/.claude/skills/qodo-pr-resolver/resources/providers.md +329 -0
  24. package/.claude/skills/setup/SKILL.md +218 -0
  25. package/.claude/skills/update-nanoclaw/SKILL.md +235 -0
  26. package/.claude/skills/update-skills/SKILL.md +130 -0
  27. package/.claude/skills/use-local-whisper/SKILL.md +152 -0
  28. package/.claude/skills/x-integration/SKILL.md +417 -0
  29. package/.claude/skills/x-integration/agent.ts +243 -0
  30. package/.claude/skills/x-integration/host.ts +159 -0
  31. package/.claude/skills/x-integration/lib/browser.ts +148 -0
  32. package/.claude/skills/x-integration/lib/config.ts +62 -0
  33. package/.claude/skills/x-integration/scripts/like.ts +56 -0
  34. package/.claude/skills/x-integration/scripts/post.ts +66 -0
  35. package/.claude/skills/x-integration/scripts/quote.ts +80 -0
  36. package/.claude/skills/x-integration/scripts/reply.ts +74 -0
  37. package/.claude/skills/x-integration/scripts/retweet.ts +62 -0
  38. package/.claude/skills/x-integration/scripts/setup.ts +87 -0
  39. package/.env.example +1 -0
  40. package/.github/CODEOWNERS +10 -0
  41. package/.github/PULL_REQUEST_TEMPLATE.md +14 -0
  42. package/.github/workflows/bump-version.yml +32 -0
  43. package/.github/workflows/ci.yml +25 -0
  44. package/.github/workflows/merge-forward-skills.yml +160 -0
  45. package/.github/workflows/update-tokens.yml +42 -0
  46. package/.husky/pre-commit +1 -0
  47. package/.mcp.json +3 -0
  48. package/.nvmrc +1 -0
  49. package/.prettierrc +3 -0
  50. package/CHANGELOG.md +8 -0
  51. package/CLAUDE.md +64 -0
  52. package/CONTRIBUTING.md +23 -0
  53. package/CONTRIBUTORS.md +15 -0
  54. package/LICENSE +21 -0
  55. package/NanoClaw_with_Web-Support.md +325 -0
  56. package/README.md +261 -0
  57. package/README_zh.md +200 -0
  58. package/assets/nanoclaw-favicon.png +0 -0
  59. package/assets/nanoclaw-icon.png +0 -0
  60. package/assets/nanoclaw-logo-dark.png +0 -0
  61. package/assets/nanoclaw-logo.png +0 -0
  62. package/assets/nanoclaw-profile.jpeg +0 -0
  63. package/assets/nanoclaw-sales.png +0 -0
  64. package/assets/social-preview.jpg +0 -0
  65. package/config-examples/mount-allowlist.json +25 -0
  66. package/container/Dockerfile +70 -0
  67. package/container/agent-runner/package.json +21 -0
  68. package/container/agent-runner/src/index.ts +774 -0
  69. package/container/agent-runner/src/ipc-mcp-stdio.ts +338 -0
  70. package/container/agent-runner/tsconfig.json +15 -0
  71. package/container/build.sh +23 -0
  72. package/container/skills/agent-browser/SKILL.md +159 -0
  73. package/container/skills/capabilities/SKILL.md +100 -0
  74. package/container/skills/cwd/SKILL.md +32 -0
  75. package/container/skills/pwd/SKILL.md +19 -0
  76. package/container/skills/status/SKILL.md +104 -0
  77. package/dist/channels/index.d.ts +2 -0
  78. package/dist/channels/index.d.ts.map +1 -0
  79. package/dist/channels/index.js +10 -0
  80. package/dist/channels/index.js.map +1 -0
  81. package/dist/channels/registry.d.ts +13 -0
  82. package/dist/channels/registry.d.ts.map +1 -0
  83. package/dist/channels/registry.js +11 -0
  84. package/dist/channels/registry.js.map +1 -0
  85. package/dist/channels/registry.test.d.ts +2 -0
  86. package/dist/channels/registry.test.d.ts.map +1 -0
  87. package/dist/channels/registry.test.js +32 -0
  88. package/dist/channels/registry.test.js.map +1 -0
  89. package/dist/channels/web.d.ts +2 -0
  90. package/dist/channels/web.d.ts.map +1 -0
  91. package/dist/channels/web.js +1843 -0
  92. package/dist/channels/web.js.map +1 -0
  93. package/dist/cli.d.ts +11 -0
  94. package/dist/cli.d.ts.map +1 -0
  95. package/dist/cli.js +182 -0
  96. package/dist/cli.js.map +1 -0
  97. package/dist/config.d.ts +19 -0
  98. package/dist/config.d.ts.map +1 -0
  99. package/dist/config.js +36 -0
  100. package/dist/config.js.map +1 -0
  101. package/dist/container-runner.d.ts +44 -0
  102. package/dist/container-runner.d.ts.map +1 -0
  103. package/dist/container-runner.js +511 -0
  104. package/dist/container-runner.js.map +1 -0
  105. package/dist/container-runner.test.d.ts +2 -0
  106. package/dist/container-runner.test.d.ts.map +1 -0
  107. package/dist/container-runner.test.js +150 -0
  108. package/dist/container-runner.test.js.map +1 -0
  109. package/dist/container-runtime.d.ts +22 -0
  110. package/dist/container-runtime.d.ts.map +1 -0
  111. package/dist/container-runtime.js +96 -0
  112. package/dist/container-runtime.js.map +1 -0
  113. package/dist/container-runtime.test.d.ts +2 -0
  114. package/dist/container-runtime.test.d.ts.map +1 -0
  115. package/dist/container-runtime.test.js +93 -0
  116. package/dist/container-runtime.test.js.map +1 -0
  117. package/dist/credential-proxy.d.ts +21 -0
  118. package/dist/credential-proxy.d.ts.map +1 -0
  119. package/dist/credential-proxy.js +95 -0
  120. package/dist/credential-proxy.js.map +1 -0
  121. package/dist/credential-proxy.test.d.ts +2 -0
  122. package/dist/credential-proxy.test.d.ts.map +1 -0
  123. package/dist/credential-proxy.test.js +134 -0
  124. package/dist/credential-proxy.test.js.map +1 -0
  125. package/dist/db.d.ts +115 -0
  126. package/dist/db.d.ts.map +1 -0
  127. package/dist/db.js +549 -0
  128. package/dist/db.js.map +1 -0
  129. package/dist/db.test.d.ts +2 -0
  130. package/dist/db.test.d.ts.map +1 -0
  131. package/dist/db.test.js +360 -0
  132. package/dist/db.test.js.map +1 -0
  133. package/dist/env.d.ts +8 -0
  134. package/dist/env.d.ts.map +1 -0
  135. package/dist/env.js +42 -0
  136. package/dist/env.js.map +1 -0
  137. package/dist/formatting.test.d.ts +2 -0
  138. package/dist/formatting.test.d.ts.map +1 -0
  139. package/dist/formatting.test.js +183 -0
  140. package/dist/formatting.test.js.map +1 -0
  141. package/dist/group-folder.d.ts +5 -0
  142. package/dist/group-folder.d.ts.map +1 -0
  143. package/dist/group-folder.js +44 -0
  144. package/dist/group-folder.js.map +1 -0
  145. package/dist/group-folder.test.d.ts +2 -0
  146. package/dist/group-folder.test.d.ts.map +1 -0
  147. package/dist/group-folder.test.js +29 -0
  148. package/dist/group-folder.test.js.map +1 -0
  149. package/dist/group-queue.d.ts +40 -0
  150. package/dist/group-queue.d.ts.map +1 -0
  151. package/dist/group-queue.js +276 -0
  152. package/dist/group-queue.js.map +1 -0
  153. package/dist/group-queue.test.d.ts +2 -0
  154. package/dist/group-queue.test.d.ts.map +1 -0
  155. package/dist/group-queue.test.js +341 -0
  156. package/dist/group-queue.test.js.map +1 -0
  157. package/dist/index.d.ts +13 -0
  158. package/dist/index.d.ts.map +1 -0
  159. package/dist/index.js +592 -0
  160. package/dist/index.js.map +1 -0
  161. package/dist/ipc-auth.test.d.ts +2 -0
  162. package/dist/ipc-auth.test.d.ts.map +1 -0
  163. package/dist/ipc-auth.test.js +434 -0
  164. package/dist/ipc-auth.test.js.map +1 -0
  165. package/dist/ipc.d.ts +32 -0
  166. package/dist/ipc.d.ts.map +1 -0
  167. package/dist/ipc.js +311 -0
  168. package/dist/ipc.js.map +1 -0
  169. package/dist/logger.d.ts +3 -0
  170. package/dist/logger.d.ts.map +1 -0
  171. package/dist/logger.js +14 -0
  172. package/dist/logger.js.map +1 -0
  173. package/dist/mount-security.d.ts +34 -0
  174. package/dist/mount-security.d.ts.map +1 -0
  175. package/dist/mount-security.js +325 -0
  176. package/dist/mount-security.js.map +1 -0
  177. package/dist/remote-control.d.ts +32 -0
  178. package/dist/remote-control.d.ts.map +1 -0
  179. package/dist/remote-control.js +185 -0
  180. package/dist/remote-control.js.map +1 -0
  181. package/dist/remote-control.test.d.ts +2 -0
  182. package/dist/remote-control.test.d.ts.map +1 -0
  183. package/dist/remote-control.test.js +321 -0
  184. package/dist/remote-control.test.js.map +1 -0
  185. package/dist/router.d.ts +8 -0
  186. package/dist/router.d.ts.map +1 -0
  187. package/dist/router.js +37 -0
  188. package/dist/router.js.map +1 -0
  189. package/dist/routing.test.d.ts +2 -0
  190. package/dist/routing.test.d.ts.map +1 -0
  191. package/dist/routing.test.js +81 -0
  192. package/dist/routing.test.js.map +1 -0
  193. package/dist/sender-allowlist.d.ts +14 -0
  194. package/dist/sender-allowlist.d.ts.map +1 -0
  195. package/dist/sender-allowlist.js +79 -0
  196. package/dist/sender-allowlist.js.map +1 -0
  197. package/dist/sender-allowlist.test.d.ts +2 -0
  198. package/dist/sender-allowlist.test.d.ts.map +1 -0
  199. package/dist/sender-allowlist.test.js +186 -0
  200. package/dist/sender-allowlist.test.js.map +1 -0
  201. package/dist/session-commands.d.ts +47 -0
  202. package/dist/session-commands.d.ts.map +1 -0
  203. package/dist/session-commands.js +104 -0
  204. package/dist/session-commands.js.map +1 -0
  205. package/dist/session-commands.test.d.ts +2 -0
  206. package/dist/session-commands.test.d.ts.map +1 -0
  207. package/dist/session-commands.test.js +194 -0
  208. package/dist/session-commands.test.js.map +1 -0
  209. package/dist/task-scheduler.d.ts +22 -0
  210. package/dist/task-scheduler.d.ts.map +1 -0
  211. package/dist/task-scheduler.js +241 -0
  212. package/dist/task-scheduler.js.map +1 -0
  213. package/dist/task-scheduler.test.d.ts +2 -0
  214. package/dist/task-scheduler.test.d.ts.map +1 -0
  215. package/dist/task-scheduler.test.js +107 -0
  216. package/dist/task-scheduler.test.js.map +1 -0
  217. package/dist/timezone.d.ts +6 -0
  218. package/dist/timezone.d.ts.map +1 -0
  219. package/dist/timezone.js +17 -0
  220. package/dist/timezone.js.map +1 -0
  221. package/dist/timezone.test.d.ts +2 -0
  222. package/dist/timezone.test.d.ts.map +1 -0
  223. package/dist/timezone.test.js +23 -0
  224. package/dist/timezone.test.js.map +1 -0
  225. package/dist/types.d.ts +79 -0
  226. package/dist/types.d.ts.map +1 -0
  227. package/dist/types.js +2 -0
  228. package/dist/types.js.map +1 -0
  229. package/docs/APPLE-CONTAINER-NETWORKING.md +90 -0
  230. package/docs/DEBUG_CHECKLIST.md +143 -0
  231. package/docs/REQUIREMENTS.md +196 -0
  232. package/docs/SDK_DEEP_DIVE.md +643 -0
  233. package/docs/SECURITY.md +122 -0
  234. package/docs/SPEC.md +785 -0
  235. package/docs/docker-sandboxes.md +359 -0
  236. package/docs/nanoclaw-architecture-final.md +1063 -0
  237. package/docs/nanorepo-architecture.md +168 -0
  238. package/docs/skills-as-branches.md +662 -0
  239. package/groups/global/CLAUDE.md +58 -0
  240. package/groups/main/CLAUDE.md +246 -0
  241. package/launchd/com.nanoclaw.plist +32 -0
  242. package/package.json +45 -0
  243. package/repo-tokens/README.md +113 -0
  244. package/repo-tokens/action.yml +186 -0
  245. package/repo-tokens/badge.svg +23 -0
  246. package/repo-tokens/examples/green.svg +14 -0
  247. package/repo-tokens/examples/red.svg +14 -0
  248. package/repo-tokens/examples/yellow-green.svg +14 -0
  249. package/repo-tokens/examples/yellow.svg +14 -0
  250. package/scripts/run-migrations.ts +105 -0
  251. package/setup/container.ts +144 -0
  252. package/setup/environment.test.ts +121 -0
  253. package/setup/environment.ts +94 -0
  254. package/setup/groups.ts +229 -0
  255. package/setup/index.ts +58 -0
  256. package/setup/mounts.ts +115 -0
  257. package/setup/platform.test.ts +120 -0
  258. package/setup/platform.ts +132 -0
  259. package/setup/register.test.ts +257 -0
  260. package/setup/register.ts +177 -0
  261. package/setup/service.test.ts +187 -0
  262. package/setup/service.ts +362 -0
  263. package/setup/status.ts +16 -0
  264. package/setup/verify.ts +192 -0
  265. package/setup.sh +161 -0
  266. package/src/channels/index.ts +15 -0
  267. package/src/channels/registry.test.ts +42 -0
  268. package/src/channels/registry.ts +32 -0
  269. package/src/channels/web.ts +1931 -0
  270. package/src/cli.ts +209 -0
  271. package/src/config.ts +73 -0
  272. package/src/container-runner.test.ts +210 -0
  273. package/src/container-runner.ts +768 -0
  274. package/src/container-runtime.test.ts +149 -0
  275. package/src/container-runtime.ts +127 -0
  276. package/src/credential-proxy.test.ts +192 -0
  277. package/src/credential-proxy.ts +125 -0
  278. package/src/db.test.ts +484 -0
  279. package/src/db.ts +803 -0
  280. package/src/env.ts +42 -0
  281. package/src/formatting.test.ts +256 -0
  282. package/src/group-folder.test.ts +43 -0
  283. package/src/group-folder.ts +44 -0
  284. package/src/group-queue.test.ts +484 -0
  285. package/src/group-queue.ts +379 -0
  286. package/src/index.ts +832 -0
  287. package/src/ipc-auth.test.ts +679 -0
  288. package/src/ipc.ts +461 -0
  289. package/src/logger.ts +16 -0
  290. package/src/mount-security.ts +419 -0
  291. package/src/remote-control.test.ts +397 -0
  292. package/src/remote-control.ts +224 -0
  293. package/src/router.ts +52 -0
  294. package/src/routing.test.ts +170 -0
  295. package/src/sender-allowlist.test.ts +216 -0
  296. package/src/sender-allowlist.ts +128 -0
  297. package/src/session-commands.test.ts +247 -0
  298. package/src/session-commands.ts +163 -0
  299. package/src/task-scheduler.test.ts +129 -0
  300. package/src/task-scheduler.ts +328 -0
  301. package/src/timezone.test.ts +29 -0
  302. package/src/timezone.ts +16 -0
  303. package/src/types.ts +109 -0
  304. package/tsconfig.json +20 -0
  305. package/vitest.config.ts +7 -0
  306. package/vitest.skills.config.ts +7 -0
@@ -0,0 +1,328 @@
1
+ import { ChildProcess } from 'child_process';
2
+ import { CronExpressionParser } from 'cron-parser';
3
+ import fs from 'fs';
4
+
5
+ import { ASSISTANT_NAME, SCHEDULER_POLL_INTERVAL, TIMEZONE } from './config.js';
6
+ import {
7
+ ContainerOutput,
8
+ runContainerAgent,
9
+ writeTasksSnapshot,
10
+ } from './container-runner.js';
11
+ import {
12
+ getAllTasks,
13
+ getDueTasks,
14
+ getTaskById,
15
+ logTaskRun,
16
+ updateTask,
17
+ updateTaskAfterRun,
18
+ } from './db.js';
19
+ import { GroupQueue } from './group-queue.js';
20
+ import { resolveGroupFolderPath } from './group-folder.js';
21
+ import { logger } from './logger.js';
22
+ import { RegisteredGroup, ScheduledTask } from './types.js';
23
+
24
+ // Web-channel JID prefix and dedicated cron session JID.
25
+ // Scheduled task output for web sessions is always routed to the cron session
26
+ // so users see it in one dedicated place instead of the originating session.
27
+ const WEB_JID_PREFIX = 'local@web-';
28
+ const WEB_JID_LEGACY = 'local@web'; // Legacy JID used before per-session JIDs were introduced
29
+ const CRON_SESSION_JID = 'local@web-cron';
30
+
31
+ /**
32
+ * Compute the next run time for a recurring task, anchored to the
33
+ * task's scheduled time rather than Date.now() to prevent cumulative
34
+ * drift on interval-based tasks.
35
+ *
36
+ * Co-authored-by: @community-pr-601
37
+ */
38
+ export function computeNextRun(task: ScheduledTask): string | null {
39
+ if (task.schedule_type === 'once') return null;
40
+
41
+ const now = Date.now();
42
+
43
+ if (task.schedule_type === 'cron') {
44
+ const interval = CronExpressionParser.parse(task.schedule_value, {
45
+ tz: TIMEZONE,
46
+ });
47
+ return interval.next().toISOString();
48
+ }
49
+
50
+ if (task.schedule_type === 'interval') {
51
+ const ms = parseInt(task.schedule_value, 10);
52
+ if (!ms || ms <= 0) {
53
+ // Guard against malformed interval that would cause an infinite loop
54
+ logger.warn(
55
+ { taskId: task.id, value: task.schedule_value },
56
+ 'Invalid interval value',
57
+ );
58
+ return new Date(now + 60_000).toISOString();
59
+ }
60
+ // Anchor to the scheduled time, not now, to prevent drift.
61
+ // Skip past any missed intervals so we always land in the future.
62
+ let next = new Date(task.next_run!).getTime() + ms;
63
+ while (next <= now) {
64
+ next += ms;
65
+ }
66
+ return new Date(next).toISOString();
67
+ }
68
+
69
+ return null;
70
+ }
71
+
72
+ export interface SchedulerDependencies {
73
+ registeredGroups: () => Record<string, RegisteredGroup>;
74
+ getSessions: () => Record<string, string>;
75
+ queue: GroupQueue;
76
+ onProcess: (
77
+ groupJid: string,
78
+ proc: ChildProcess,
79
+ containerName: string,
80
+ groupFolder: string,
81
+ ) => void;
82
+ sendMessage: (jid: string, text: string) => Promise<void>;
83
+ }
84
+
85
+ async function runTask(
86
+ task: ScheduledTask,
87
+ deps: SchedulerDependencies,
88
+ ): Promise<void> {
89
+ const startTime = Date.now();
90
+ let groupDir: string;
91
+ try {
92
+ groupDir = resolveGroupFolderPath(task.group_folder);
93
+ } catch (err) {
94
+ const error = err instanceof Error ? err.message : String(err);
95
+ // Stop retry churn for malformed legacy rows.
96
+ updateTask(task.id, { status: 'paused' });
97
+ logger.error(
98
+ { taskId: task.id, groupFolder: task.group_folder, error },
99
+ 'Task has invalid group folder',
100
+ );
101
+ logTaskRun({
102
+ task_id: task.id,
103
+ run_at: new Date().toISOString(),
104
+ duration_ms: Date.now() - startTime,
105
+ status: 'error',
106
+ result: null,
107
+ error,
108
+ });
109
+ return;
110
+ }
111
+ fs.mkdirSync(groupDir, { recursive: true });
112
+
113
+ logger.info(
114
+ { taskId: task.id, group: task.group_folder },
115
+ 'Running scheduled task',
116
+ );
117
+
118
+ const groups = deps.registeredGroups();
119
+ let group = Object.values(groups).find((g) => g.folder === task.group_folder);
120
+
121
+ if (!group) {
122
+ // Fall back to the first isMain group so that legacy tasks created with
123
+ // group_folder='main' still run after a web-channel migration.
124
+ const fallback = Object.values(groups).find((g) => g.isMain === true);
125
+ if (fallback) {
126
+ logger.warn(
127
+ {
128
+ taskId: task.id,
129
+ groupFolder: task.group_folder,
130
+ fallback: fallback.folder,
131
+ },
132
+ 'Group not found for task, falling back to main group',
133
+ );
134
+ group = fallback;
135
+ } else {
136
+ logger.error(
137
+ { taskId: task.id, groupFolder: task.group_folder },
138
+ 'Group not found for task',
139
+ );
140
+ logTaskRun({
141
+ task_id: task.id,
142
+ run_at: new Date().toISOString(),
143
+ duration_ms: Date.now() - startTime,
144
+ status: 'error',
145
+ result: null,
146
+ error: `Group not found: ${task.group_folder}`,
147
+ });
148
+ return;
149
+ }
150
+ }
151
+
152
+ // Update tasks snapshot for container to read (filtered by group)
153
+ const isMain = group.isMain === true;
154
+ const tasks = getAllTasks();
155
+ writeTasksSnapshot(
156
+ group.folder,
157
+ isMain,
158
+ tasks.map((t) => ({
159
+ id: t.id,
160
+ groupFolder: t.group_folder,
161
+ prompt: t.prompt,
162
+ schedule_type: t.schedule_type,
163
+ schedule_value: t.schedule_value,
164
+ status: t.status,
165
+ next_run: t.next_run,
166
+ })),
167
+ );
168
+
169
+ let result: string | null = null;
170
+ let error: string | null = null;
171
+
172
+ // For web sessions, display output in the dedicated cron session instead of
173
+ // the originating session, so all scheduled-task output is in one place.
174
+ // Also handle legacy JID 'local@web' (without suffix) used before per-session JIDs.
175
+ const displayJid =
176
+ task.chat_jid === WEB_JID_LEGACY || task.chat_jid.startsWith(WEB_JID_PREFIX)
177
+ ? CRON_SESSION_JID
178
+ : task.chat_jid;
179
+
180
+ // For group context mode, look up the session by chatJid first (per-session key),
181
+ // then fall back to group_folder (backward compatibility).
182
+ const sessions = deps.getSessions();
183
+ const sessionId =
184
+ task.context_mode === 'group'
185
+ ? (sessions[task.chat_jid] ?? sessions[task.group_folder])
186
+ : undefined;
187
+
188
+ // Each task gets its own queue JID so it runs in parallel with user messages
189
+ // and with other tasks, without blocking the user's message queue slot.
190
+ const taskQueueJid = `task:${task.id}`;
191
+
192
+ // Send a header to the cron session so the user knows which task is running.
193
+ const promptPreview = task.prompt.split('\n')[0].slice(0, 80);
194
+ const scheduleLabel =
195
+ task.schedule_type === 'interval'
196
+ ? `every ${Math.round(parseInt(task.schedule_value, 10) / 60000)} min`
197
+ : task.schedule_value;
198
+ const taskHeader = `---\n**Task #${task.id}** | ${scheduleLabel}\n> ${promptPreview}`;
199
+ await deps.sendMessage(displayJid, taskHeader);
200
+
201
+ // After the task produces a result, close the container promptly.
202
+ // Tasks are single-turn — no need to wait IDLE_TIMEOUT (30 min) for the
203
+ // query loop to time out. A short delay handles any final MCP calls.
204
+ const TASK_CLOSE_DELAY_MS = 10000;
205
+ let closeTimer: ReturnType<typeof setTimeout> | null = null;
206
+
207
+ const scheduleClose = () => {
208
+ if (closeTimer) return; // already scheduled
209
+ closeTimer = setTimeout(() => {
210
+ logger.debug({ taskId: task.id }, 'Closing task container after result');
211
+ deps.queue.closeStdin(taskQueueJid);
212
+ }, TASK_CLOSE_DELAY_MS);
213
+ };
214
+
215
+ try {
216
+ const output = await runContainerAgent(
217
+ group,
218
+ {
219
+ prompt: task.prompt,
220
+ sessionId,
221
+ groupFolder: group.folder,
222
+ chatJid: task.chat_jid,
223
+ isMain,
224
+ isScheduledTask: true,
225
+ assistantName: ASSISTANT_NAME,
226
+ },
227
+ (proc, containerName) =>
228
+ deps.onProcess(taskQueueJid, proc, containerName, group.folder),
229
+ async (streamedOutput: ContainerOutput) => {
230
+ if (streamedOutput.result) {
231
+ result = streamedOutput.result;
232
+ // Forward result to the display JID (cron session for web tasks)
233
+ await deps.sendMessage(displayJid, streamedOutput.result);
234
+ scheduleClose();
235
+ }
236
+ if (streamedOutput.status === 'success') {
237
+ deps.queue.notifyIdle(taskQueueJid);
238
+ scheduleClose(); // Close promptly even when result is null (e.g. IPC-only tasks)
239
+ }
240
+ if (streamedOutput.status === 'error') {
241
+ error = streamedOutput.error || 'Unknown error';
242
+ }
243
+ },
244
+ );
245
+
246
+ if (closeTimer) clearTimeout(closeTimer);
247
+
248
+ if (output.status === 'error') {
249
+ error = output.error || 'Unknown error';
250
+ } else if (output.result) {
251
+ // Result was already forwarded to the user via the streaming callback above
252
+ result = output.result;
253
+ }
254
+
255
+ logger.info(
256
+ { taskId: task.id, durationMs: Date.now() - startTime },
257
+ 'Task completed',
258
+ );
259
+ } catch (err) {
260
+ if (closeTimer) clearTimeout(closeTimer);
261
+ error = err instanceof Error ? err.message : String(err);
262
+ logger.error({ taskId: task.id, error }, 'Task failed');
263
+ }
264
+
265
+ const durationMs = Date.now() - startTime;
266
+
267
+ logTaskRun({
268
+ task_id: task.id,
269
+ run_at: new Date().toISOString(),
270
+ duration_ms: durationMs,
271
+ status: error ? 'error' : 'success',
272
+ result,
273
+ error,
274
+ });
275
+
276
+ const nextRun = computeNextRun(task);
277
+ const resultSummary = error
278
+ ? `Error: ${error}`
279
+ : result
280
+ ? result.slice(0, 200)
281
+ : 'Completed';
282
+ updateTaskAfterRun(task.id, nextRun, resultSummary);
283
+ }
284
+
285
+ let schedulerRunning = false;
286
+
287
+ export function startSchedulerLoop(deps: SchedulerDependencies): void {
288
+ if (schedulerRunning) {
289
+ logger.debug('Scheduler loop already running, skipping duplicate start');
290
+ return;
291
+ }
292
+ schedulerRunning = true;
293
+ logger.info('Scheduler loop started');
294
+
295
+ const loop = async () => {
296
+ try {
297
+ const dueTasks = getDueTasks();
298
+ if (dueTasks.length > 0) {
299
+ logger.info({ count: dueTasks.length }, 'Found due tasks');
300
+ }
301
+
302
+ for (const task of dueTasks) {
303
+ // Re-check task status in case it was paused/cancelled
304
+ const currentTask = getTaskById(task.id);
305
+ if (!currentTask || currentTask.status !== 'active') {
306
+ continue;
307
+ }
308
+
309
+ // Use a task-specific queue JID so the task runs in parallel with
310
+ // user messages and other tasks (not serialized on the group's slot).
311
+ deps.queue.enqueueTask(`task:${currentTask.id}`, currentTask.id, () =>
312
+ runTask(currentTask, deps),
313
+ );
314
+ }
315
+ } catch (err) {
316
+ logger.error({ err }, 'Error in scheduler loop');
317
+ }
318
+
319
+ setTimeout(loop, SCHEDULER_POLL_INTERVAL);
320
+ };
321
+
322
+ loop();
323
+ }
324
+
325
+ /** @internal - for tests only. */
326
+ export function _resetSchedulerLoopForTests(): void {
327
+ schedulerRunning = false;
328
+ }
@@ -0,0 +1,29 @@
1
+ import { describe, it, expect } from 'vitest';
2
+
3
+ import { formatLocalTime } from './timezone.js';
4
+
5
+ // --- formatLocalTime ---
6
+
7
+ describe('formatLocalTime', () => {
8
+ it('converts UTC to local time display', () => {
9
+ // 2026-02-04T18:30:00Z in America/New_York (EST, UTC-5) = 1:30 PM
10
+ const result = formatLocalTime(
11
+ '2026-02-04T18:30:00.000Z',
12
+ 'America/New_York',
13
+ );
14
+ expect(result).toContain('1:30');
15
+ expect(result).toContain('PM');
16
+ expect(result).toContain('Feb');
17
+ expect(result).toContain('2026');
18
+ });
19
+
20
+ it('handles different timezones', () => {
21
+ // Same UTC time should produce different local times
22
+ const utc = '2026-06-15T12:00:00.000Z';
23
+ const ny = formatLocalTime(utc, 'America/New_York');
24
+ const tokyo = formatLocalTime(utc, 'Asia/Tokyo');
25
+ // NY is UTC-4 in summer (EDT), Tokyo is UTC+9
26
+ expect(ny).toContain('8:00');
27
+ expect(tokyo).toContain('9:00');
28
+ });
29
+ });
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Convert a UTC ISO timestamp to a localized display string.
3
+ * Uses the Intl API (no external dependencies).
4
+ */
5
+ export function formatLocalTime(utcIso: string, timezone: string): string {
6
+ const date = new Date(utcIso);
7
+ return date.toLocaleString('en-US', {
8
+ timeZone: timezone,
9
+ year: 'numeric',
10
+ month: 'short',
11
+ day: 'numeric',
12
+ hour: 'numeric',
13
+ minute: '2-digit',
14
+ hour12: true,
15
+ });
16
+ }
package/src/types.ts ADDED
@@ -0,0 +1,109 @@
1
+ export interface AdditionalMount {
2
+ hostPath: string; // Absolute path on host (supports ~ for home)
3
+ containerPath?: string; // Optional — defaults to basename of hostPath. Mounted at /workspace/extra/{value}
4
+ readonly?: boolean; // Default: true for safety
5
+ }
6
+
7
+ /**
8
+ * Mount Allowlist - Security configuration for additional mounts
9
+ * This file should be stored at ~/.config/nanoclaw/mount-allowlist.json
10
+ * and is NOT mounted into any container, making it tamper-proof from agents.
11
+ */
12
+ export interface MountAllowlist {
13
+ // Directories that can be mounted into containers
14
+ allowedRoots: AllowedRoot[];
15
+ // Glob patterns for paths that should never be mounted (e.g., ".ssh", ".gnupg")
16
+ blockedPatterns: string[];
17
+ // If true, non-main groups can only mount read-only regardless of config
18
+ nonMainReadOnly: boolean;
19
+ }
20
+
21
+ export interface AllowedRoot {
22
+ // Absolute path or ~ for home (e.g., "~/projects", "/var/repos")
23
+ path: string;
24
+ // Whether read-write mounts are allowed under this root
25
+ allowReadWrite: boolean;
26
+ // Optional description for documentation
27
+ description?: string;
28
+ }
29
+
30
+ export interface ContainerConfig {
31
+ additionalMounts?: AdditionalMount[];
32
+ timeout?: number; // Default: 300000 (5 minutes)
33
+ }
34
+
35
+ export interface RegisteredGroup {
36
+ name: string;
37
+ folder: string;
38
+ trigger: string;
39
+ added_at: string;
40
+ containerConfig?: ContainerConfig;
41
+ requiresTrigger?: boolean; // Default: true for groups, false for solo chats
42
+ isMain?: boolean; // True for the main control group (no trigger, elevated privileges)
43
+ }
44
+
45
+ export interface NewMessage {
46
+ id: string;
47
+ chat_jid: string;
48
+ sender: string;
49
+ sender_name: string;
50
+ content: string;
51
+ timestamp: string;
52
+ is_from_me?: boolean;
53
+ is_bot_message?: boolean;
54
+ }
55
+
56
+ export interface ScheduledTask {
57
+ id: string;
58
+ group_folder: string;
59
+ chat_jid: string;
60
+ prompt: string;
61
+ schedule_type: 'cron' | 'interval' | 'once';
62
+ schedule_value: string;
63
+ context_mode: 'group' | 'isolated';
64
+ next_run: string | null;
65
+ last_run: string | null;
66
+ last_result: string | null;
67
+ status: 'active' | 'paused' | 'completed';
68
+ created_at: string;
69
+ }
70
+
71
+ export interface TaskRunLog {
72
+ task_id: string;
73
+ run_at: string;
74
+ duration_ms: number;
75
+ status: 'success' | 'error';
76
+ result: string | null;
77
+ error: string | null;
78
+ }
79
+
80
+ // --- Channel abstraction ---
81
+
82
+ export interface Channel {
83
+ name: string;
84
+ connect(): Promise<void>;
85
+ sendMessage(jid: string, text: string): Promise<void>;
86
+ isConnected(): boolean;
87
+ ownsJid(jid: string): boolean;
88
+ disconnect(): Promise<void>;
89
+ // Optional: typing indicator. Channels that support it implement it.
90
+ setTyping?(jid: string, isTyping: boolean): Promise<void>;
91
+ // Optional: sync group/chat names from the platform.
92
+ syncGroups?(force: boolean): Promise<void>;
93
+ // Optional: live tool-use status. Call with null to clear the status display.
94
+ setStatus?(jid: string, tool: string | null, inputSnippet?: string): void;
95
+ }
96
+
97
+ // Callback type that channels use to deliver inbound messages
98
+ export type OnInboundMessage = (chatJid: string, message: NewMessage) => void;
99
+
100
+ // Callback for chat metadata discovery.
101
+ // name is optional — channels that deliver names inline (Telegram) pass it here;
102
+ // channels that sync names separately (via syncGroups) omit it.
103
+ export type OnChatMetadata = (
104
+ chatJid: string,
105
+ timestamp: string,
106
+ name?: string,
107
+ channel?: string,
108
+ isGroup?: boolean,
109
+ ) => void;
package/tsconfig.json ADDED
@@ -0,0 +1,20 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "NodeNext",
5
+ "moduleResolution": "NodeNext",
6
+ "lib": ["ES2022"],
7
+ "outDir": "./dist",
8
+ "rootDir": "./src",
9
+ "strict": true,
10
+ "esModuleInterop": true,
11
+ "skipLibCheck": true,
12
+ "forceConsistentCasingInFileNames": true,
13
+ "resolveJsonModule": true,
14
+ "declaration": true,
15
+ "declarationMap": true,
16
+ "sourceMap": true
17
+ },
18
+ "include": ["src/**/*"],
19
+ "exclude": ["node_modules", "dist"]
20
+ }
@@ -0,0 +1,7 @@
1
+ import { defineConfig } from 'vitest/config';
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ include: ['src/**/*.test.ts', 'setup/**/*.test.ts'],
6
+ },
7
+ });
@@ -0,0 +1,7 @@
1
+ import { defineConfig } from 'vitest/config';
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ include: ['.claude/skills/**/tests/*.test.ts'],
6
+ },
7
+ });