@rozek/nanoclaw 1.2.17

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 (305) 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 +290 -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-lock.json +1524 -0
  68. package/container/agent-runner/package.json +21 -0
  69. package/container/agent-runner/src/index.ts +558 -0
  70. package/container/agent-runner/src/ipc-mcp-stdio.ts +338 -0
  71. package/container/agent-runner/tsconfig.json +15 -0
  72. package/container/build.sh +23 -0
  73. package/container/skills/agent-browser/SKILL.md +159 -0
  74. package/container/skills/capabilities/SKILL.md +100 -0
  75. package/container/skills/status/SKILL.md +104 -0
  76. package/dist/channels/index.d.ts +2 -0
  77. package/dist/channels/index.d.ts.map +1 -0
  78. package/dist/channels/index.js +9 -0
  79. package/dist/channels/index.js.map +1 -0
  80. package/dist/channels/registry.d.ts +13 -0
  81. package/dist/channels/registry.d.ts.map +1 -0
  82. package/dist/channels/registry.js +11 -0
  83. package/dist/channels/registry.js.map +1 -0
  84. package/dist/channels/registry.test.d.ts +2 -0
  85. package/dist/channels/registry.test.d.ts.map +1 -0
  86. package/dist/channels/registry.test.js +32 -0
  87. package/dist/channels/registry.test.js.map +1 -0
  88. package/dist/channels/web.d.ts +2 -0
  89. package/dist/channels/web.d.ts.map +1 -0
  90. package/dist/channels/web.js +1738 -0
  91. package/dist/channels/web.js.map +1 -0
  92. package/dist/cli.d.ts +11 -0
  93. package/dist/cli.d.ts.map +1 -0
  94. package/dist/cli.js +182 -0
  95. package/dist/cli.js.map +1 -0
  96. package/dist/config.d.ts +19 -0
  97. package/dist/config.d.ts.map +1 -0
  98. package/dist/config.js +36 -0
  99. package/dist/config.js.map +1 -0
  100. package/dist/container-runner.d.ts +44 -0
  101. package/dist/container-runner.d.ts.map +1 -0
  102. package/dist/container-runner.js +467 -0
  103. package/dist/container-runner.js.map +1 -0
  104. package/dist/container-runner.test.d.ts +2 -0
  105. package/dist/container-runner.test.d.ts.map +1 -0
  106. package/dist/container-runner.test.js +150 -0
  107. package/dist/container-runner.test.js.map +1 -0
  108. package/dist/container-runtime.d.ts +22 -0
  109. package/dist/container-runtime.d.ts.map +1 -0
  110. package/dist/container-runtime.js +96 -0
  111. package/dist/container-runtime.js.map +1 -0
  112. package/dist/container-runtime.test.d.ts +2 -0
  113. package/dist/container-runtime.test.d.ts.map +1 -0
  114. package/dist/container-runtime.test.js +93 -0
  115. package/dist/container-runtime.test.js.map +1 -0
  116. package/dist/credential-proxy.d.ts +21 -0
  117. package/dist/credential-proxy.d.ts.map +1 -0
  118. package/dist/credential-proxy.js +95 -0
  119. package/dist/credential-proxy.js.map +1 -0
  120. package/dist/credential-proxy.test.d.ts +2 -0
  121. package/dist/credential-proxy.test.d.ts.map +1 -0
  122. package/dist/credential-proxy.test.js +134 -0
  123. package/dist/credential-proxy.test.js.map +1 -0
  124. package/dist/db.d.ts +115 -0
  125. package/dist/db.d.ts.map +1 -0
  126. package/dist/db.js +549 -0
  127. package/dist/db.js.map +1 -0
  128. package/dist/db.test.d.ts +2 -0
  129. package/dist/db.test.d.ts.map +1 -0
  130. package/dist/db.test.js +360 -0
  131. package/dist/db.test.js.map +1 -0
  132. package/dist/env.d.ts +8 -0
  133. package/dist/env.d.ts.map +1 -0
  134. package/dist/env.js +42 -0
  135. package/dist/env.js.map +1 -0
  136. package/dist/formatting.test.d.ts +2 -0
  137. package/dist/formatting.test.d.ts.map +1 -0
  138. package/dist/formatting.test.js +183 -0
  139. package/dist/formatting.test.js.map +1 -0
  140. package/dist/group-folder.d.ts +5 -0
  141. package/dist/group-folder.d.ts.map +1 -0
  142. package/dist/group-folder.js +44 -0
  143. package/dist/group-folder.js.map +1 -0
  144. package/dist/group-folder.test.d.ts +2 -0
  145. package/dist/group-folder.test.d.ts.map +1 -0
  146. package/dist/group-folder.test.js +29 -0
  147. package/dist/group-folder.test.js.map +1 -0
  148. package/dist/group-queue.d.ts +34 -0
  149. package/dist/group-queue.d.ts.map +1 -0
  150. package/dist/group-queue.js +263 -0
  151. package/dist/group-queue.js.map +1 -0
  152. package/dist/group-queue.test.d.ts +2 -0
  153. package/dist/group-queue.test.d.ts.map +1 -0
  154. package/dist/group-queue.test.js +341 -0
  155. package/dist/group-queue.test.js.map +1 -0
  156. package/dist/index.d.ts +12 -0
  157. package/dist/index.d.ts.map +1 -0
  158. package/dist/index.js +518 -0
  159. package/dist/index.js.map +1 -0
  160. package/dist/ipc-auth.test.d.ts +2 -0
  161. package/dist/ipc-auth.test.d.ts.map +1 -0
  162. package/dist/ipc-auth.test.js +434 -0
  163. package/dist/ipc-auth.test.js.map +1 -0
  164. package/dist/ipc.d.ts +32 -0
  165. package/dist/ipc.d.ts.map +1 -0
  166. package/dist/ipc.js +311 -0
  167. package/dist/ipc.js.map +1 -0
  168. package/dist/logger.d.ts +3 -0
  169. package/dist/logger.d.ts.map +1 -0
  170. package/dist/logger.js +14 -0
  171. package/dist/logger.js.map +1 -0
  172. package/dist/mount-security.d.ts +34 -0
  173. package/dist/mount-security.d.ts.map +1 -0
  174. package/dist/mount-security.js +325 -0
  175. package/dist/mount-security.js.map +1 -0
  176. package/dist/remote-control.d.ts +32 -0
  177. package/dist/remote-control.d.ts.map +1 -0
  178. package/dist/remote-control.js +185 -0
  179. package/dist/remote-control.js.map +1 -0
  180. package/dist/remote-control.test.d.ts +2 -0
  181. package/dist/remote-control.test.d.ts.map +1 -0
  182. package/dist/remote-control.test.js +321 -0
  183. package/dist/remote-control.test.js.map +1 -0
  184. package/dist/router.d.ts +8 -0
  185. package/dist/router.d.ts.map +1 -0
  186. package/dist/router.js +37 -0
  187. package/dist/router.js.map +1 -0
  188. package/dist/routing.test.d.ts +2 -0
  189. package/dist/routing.test.d.ts.map +1 -0
  190. package/dist/routing.test.js +81 -0
  191. package/dist/routing.test.js.map +1 -0
  192. package/dist/sender-allowlist.d.ts +14 -0
  193. package/dist/sender-allowlist.d.ts.map +1 -0
  194. package/dist/sender-allowlist.js +79 -0
  195. package/dist/sender-allowlist.js.map +1 -0
  196. package/dist/sender-allowlist.test.d.ts +2 -0
  197. package/dist/sender-allowlist.test.d.ts.map +1 -0
  198. package/dist/sender-allowlist.test.js +186 -0
  199. package/dist/sender-allowlist.test.js.map +1 -0
  200. package/dist/session-commands.d.ts +47 -0
  201. package/dist/session-commands.d.ts.map +1 -0
  202. package/dist/session-commands.js +102 -0
  203. package/dist/session-commands.js.map +1 -0
  204. package/dist/session-commands.test.d.ts +2 -0
  205. package/dist/session-commands.test.d.ts.map +1 -0
  206. package/dist/session-commands.test.js +190 -0
  207. package/dist/session-commands.test.js.map +1 -0
  208. package/dist/task-scheduler.d.ts +22 -0
  209. package/dist/task-scheduler.d.ts.map +1 -0
  210. package/dist/task-scheduler.js +210 -0
  211. package/dist/task-scheduler.js.map +1 -0
  212. package/dist/task-scheduler.test.d.ts +2 -0
  213. package/dist/task-scheduler.test.d.ts.map +1 -0
  214. package/dist/task-scheduler.test.js +107 -0
  215. package/dist/task-scheduler.test.js.map +1 -0
  216. package/dist/timezone.d.ts +6 -0
  217. package/dist/timezone.d.ts.map +1 -0
  218. package/dist/timezone.js +17 -0
  219. package/dist/timezone.js.map +1 -0
  220. package/dist/timezone.test.d.ts +2 -0
  221. package/dist/timezone.test.d.ts.map +1 -0
  222. package/dist/timezone.test.js +23 -0
  223. package/dist/timezone.test.js.map +1 -0
  224. package/dist/types.d.ts +78 -0
  225. package/dist/types.d.ts.map +1 -0
  226. package/dist/types.js +2 -0
  227. package/dist/types.js.map +1 -0
  228. package/docs/APPLE-CONTAINER-NETWORKING.md +90 -0
  229. package/docs/DEBUG_CHECKLIST.md +143 -0
  230. package/docs/REQUIREMENTS.md +196 -0
  231. package/docs/SDK_DEEP_DIVE.md +643 -0
  232. package/docs/SECURITY.md +122 -0
  233. package/docs/SPEC.md +785 -0
  234. package/docs/docker-sandboxes.md +359 -0
  235. package/docs/nanoclaw-architecture-final.md +1063 -0
  236. package/docs/nanorepo-architecture.md +168 -0
  237. package/docs/skills-as-branches.md +662 -0
  238. package/groups/global/CLAUDE.md +58 -0
  239. package/groups/main/CLAUDE.md +246 -0
  240. package/launchd/com.nanoclaw.plist +32 -0
  241. package/package.json +45 -0
  242. package/repo-tokens/README.md +113 -0
  243. package/repo-tokens/action.yml +186 -0
  244. package/repo-tokens/badge.svg +23 -0
  245. package/repo-tokens/examples/green.svg +14 -0
  246. package/repo-tokens/examples/red.svg +14 -0
  247. package/repo-tokens/examples/yellow-green.svg +14 -0
  248. package/repo-tokens/examples/yellow.svg +14 -0
  249. package/scripts/run-migrations.ts +105 -0
  250. package/setup/container.ts +144 -0
  251. package/setup/environment.test.ts +121 -0
  252. package/setup/environment.ts +94 -0
  253. package/setup/groups.ts +229 -0
  254. package/setup/index.ts +58 -0
  255. package/setup/mounts.ts +115 -0
  256. package/setup/platform.test.ts +120 -0
  257. package/setup/platform.ts +132 -0
  258. package/setup/register.test.ts +257 -0
  259. package/setup/register.ts +177 -0
  260. package/setup/service.test.ts +187 -0
  261. package/setup/service.ts +362 -0
  262. package/setup/status.ts +16 -0
  263. package/setup/verify.ts +192 -0
  264. package/setup.sh +161 -0
  265. package/src/channels/index.ts +12 -0
  266. package/src/channels/registry.test.ts +42 -0
  267. package/src/channels/registry.ts +32 -0
  268. package/src/channels/web.ts +1856 -0
  269. package/src/cli.ts +209 -0
  270. package/src/config.ts +73 -0
  271. package/src/container-runner.test.ts +210 -0
  272. package/src/container-runner.ts +707 -0
  273. package/src/container-runtime.test.ts +149 -0
  274. package/src/container-runtime.ts +127 -0
  275. package/src/credential-proxy.test.ts +192 -0
  276. package/src/credential-proxy.ts +125 -0
  277. package/src/db.test.ts +484 -0
  278. package/src/db.ts +803 -0
  279. package/src/env.ts +42 -0
  280. package/src/formatting.test.ts +256 -0
  281. package/src/group-folder.test.ts +43 -0
  282. package/src/group-folder.ts +44 -0
  283. package/src/group-queue.test.ts +484 -0
  284. package/src/group-queue.ts +365 -0
  285. package/src/index.ts +731 -0
  286. package/src/ipc-auth.test.ts +679 -0
  287. package/src/ipc.ts +461 -0
  288. package/src/logger.ts +16 -0
  289. package/src/mount-security.ts +419 -0
  290. package/src/remote-control.test.ts +397 -0
  291. package/src/remote-control.ts +224 -0
  292. package/src/router.ts +52 -0
  293. package/src/routing.test.ts +170 -0
  294. package/src/sender-allowlist.test.ts +216 -0
  295. package/src/sender-allowlist.ts +128 -0
  296. package/src/session-commands.test.ts +247 -0
  297. package/src/session-commands.ts +163 -0
  298. package/src/task-scheduler.test.ts +129 -0
  299. package/src/task-scheduler.ts +295 -0
  300. package/src/timezone.test.ts +29 -0
  301. package/src/timezone.ts +16 -0
  302. package/src/types.ts +107 -0
  303. package/tsconfig.json +20 -0
  304. package/vitest.config.ts +7 -0
  305. package/vitest.skills.config.ts +7 -0
@@ -0,0 +1,295 @@
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
+ /**
25
+ * Compute the next run time for a recurring task, anchored to the
26
+ * task's scheduled time rather than Date.now() to prevent cumulative
27
+ * drift on interval-based tasks.
28
+ *
29
+ * Co-authored-by: @community-pr-601
30
+ */
31
+ export function computeNextRun(task: ScheduledTask): string | null {
32
+ if (task.schedule_type === 'once') return null;
33
+
34
+ const now = Date.now();
35
+
36
+ if (task.schedule_type === 'cron') {
37
+ const interval = CronExpressionParser.parse(task.schedule_value, {
38
+ tz: TIMEZONE,
39
+ });
40
+ return interval.next().toISOString();
41
+ }
42
+
43
+ if (task.schedule_type === 'interval') {
44
+ const ms = parseInt(task.schedule_value, 10);
45
+ if (!ms || ms <= 0) {
46
+ // Guard against malformed interval that would cause an infinite loop
47
+ logger.warn(
48
+ { taskId: task.id, value: task.schedule_value },
49
+ 'Invalid interval value',
50
+ );
51
+ return new Date(now + 60_000).toISOString();
52
+ }
53
+ // Anchor to the scheduled time, not now, to prevent drift.
54
+ // Skip past any missed intervals so we always land in the future.
55
+ let next = new Date(task.next_run!).getTime() + ms;
56
+ while (next <= now) {
57
+ next += ms;
58
+ }
59
+ return new Date(next).toISOString();
60
+ }
61
+
62
+ return null;
63
+ }
64
+
65
+ export interface SchedulerDependencies {
66
+ registeredGroups: () => Record<string, RegisteredGroup>;
67
+ getSessions: () => Record<string, string>;
68
+ queue: GroupQueue;
69
+ onProcess: (
70
+ groupJid: string,
71
+ proc: ChildProcess,
72
+ containerName: string,
73
+ groupFolder: string,
74
+ ) => void;
75
+ sendMessage: (jid: string, text: string) => Promise<void>;
76
+ }
77
+
78
+ async function runTask(
79
+ task: ScheduledTask,
80
+ deps: SchedulerDependencies,
81
+ ): Promise<void> {
82
+ const startTime = Date.now();
83
+ let groupDir: string;
84
+ try {
85
+ groupDir = resolveGroupFolderPath(task.group_folder);
86
+ } catch (err) {
87
+ const error = err instanceof Error ? err.message : String(err);
88
+ // Stop retry churn for malformed legacy rows.
89
+ updateTask(task.id, { status: 'paused' });
90
+ logger.error(
91
+ { taskId: task.id, groupFolder: task.group_folder, error },
92
+ 'Task has invalid group folder',
93
+ );
94
+ logTaskRun({
95
+ task_id: task.id,
96
+ run_at: new Date().toISOString(),
97
+ duration_ms: Date.now() - startTime,
98
+ status: 'error',
99
+ result: null,
100
+ error,
101
+ });
102
+ return;
103
+ }
104
+ fs.mkdirSync(groupDir, { recursive: true });
105
+
106
+ logger.info(
107
+ { taskId: task.id, group: task.group_folder },
108
+ 'Running scheduled task',
109
+ );
110
+
111
+ const groups = deps.registeredGroups();
112
+ let group = Object.values(groups).find((g) => g.folder === task.group_folder);
113
+
114
+ if (!group) {
115
+ // Fall back to the first isMain group so that legacy tasks created with
116
+ // group_folder='main' still run after a web-channel migration.
117
+ const fallback = Object.values(groups).find((g) => g.isMain === true);
118
+ if (fallback) {
119
+ logger.warn(
120
+ {
121
+ taskId: task.id,
122
+ groupFolder: task.group_folder,
123
+ fallback: fallback.folder,
124
+ },
125
+ 'Group not found for task, falling back to main group',
126
+ );
127
+ group = fallback;
128
+ } else {
129
+ logger.error(
130
+ { taskId: task.id, groupFolder: task.group_folder },
131
+ 'Group not found for task',
132
+ );
133
+ logTaskRun({
134
+ task_id: task.id,
135
+ run_at: new Date().toISOString(),
136
+ duration_ms: Date.now() - startTime,
137
+ status: 'error',
138
+ result: null,
139
+ error: `Group not found: ${task.group_folder}`,
140
+ });
141
+ return;
142
+ }
143
+ }
144
+
145
+ // Update tasks snapshot for container to read (filtered by group)
146
+ const isMain = group.isMain === true;
147
+ const tasks = getAllTasks();
148
+ writeTasksSnapshot(
149
+ group.folder,
150
+ isMain,
151
+ tasks.map((t) => ({
152
+ id: t.id,
153
+ groupFolder: t.group_folder,
154
+ prompt: t.prompt,
155
+ schedule_type: t.schedule_type,
156
+ schedule_value: t.schedule_value,
157
+ status: t.status,
158
+ next_run: t.next_run,
159
+ })),
160
+ );
161
+
162
+ let result: string | null = null;
163
+ let error: string | null = null;
164
+
165
+ // For group context mode, use the group's current session
166
+ const sessions = deps.getSessions();
167
+ const sessionId =
168
+ task.context_mode === 'group' ? sessions[task.group_folder] : undefined;
169
+
170
+ // After the task produces a result, close the container promptly.
171
+ // Tasks are single-turn — no need to wait IDLE_TIMEOUT (30 min) for the
172
+ // query loop to time out. A short delay handles any final MCP calls.
173
+ const TASK_CLOSE_DELAY_MS = 10000;
174
+ let closeTimer: ReturnType<typeof setTimeout> | null = null;
175
+
176
+ const scheduleClose = () => {
177
+ if (closeTimer) return; // already scheduled
178
+ closeTimer = setTimeout(() => {
179
+ logger.debug({ taskId: task.id }, 'Closing task container after result');
180
+ deps.queue.closeStdin(task.chat_jid);
181
+ }, TASK_CLOSE_DELAY_MS);
182
+ };
183
+
184
+ try {
185
+ const output = await runContainerAgent(
186
+ group,
187
+ {
188
+ prompt: task.prompt,
189
+ sessionId,
190
+ groupFolder: group.folder,
191
+ chatJid: task.chat_jid,
192
+ isMain,
193
+ isScheduledTask: true,
194
+ assistantName: ASSISTANT_NAME,
195
+ },
196
+ (proc, containerName) =>
197
+ deps.onProcess(task.chat_jid, proc, containerName, group.folder),
198
+ async (streamedOutput: ContainerOutput) => {
199
+ if (streamedOutput.result) {
200
+ result = streamedOutput.result;
201
+ // Forward result to user (sendMessage handles formatting)
202
+ await deps.sendMessage(task.chat_jid, streamedOutput.result);
203
+ scheduleClose();
204
+ }
205
+ if (streamedOutput.status === 'success') {
206
+ deps.queue.notifyIdle(task.chat_jid);
207
+ scheduleClose(); // Close promptly even when result is null (e.g. IPC-only tasks)
208
+ }
209
+ if (streamedOutput.status === 'error') {
210
+ error = streamedOutput.error || 'Unknown error';
211
+ }
212
+ },
213
+ );
214
+
215
+ if (closeTimer) clearTimeout(closeTimer);
216
+
217
+ if (output.status === 'error') {
218
+ error = output.error || 'Unknown error';
219
+ } else if (output.result) {
220
+ // Result was already forwarded to the user via the streaming callback above
221
+ result = output.result;
222
+ }
223
+
224
+ logger.info(
225
+ { taskId: task.id, durationMs: Date.now() - startTime },
226
+ 'Task completed',
227
+ );
228
+ } catch (err) {
229
+ if (closeTimer) clearTimeout(closeTimer);
230
+ error = err instanceof Error ? err.message : String(err);
231
+ logger.error({ taskId: task.id, error }, 'Task failed');
232
+ }
233
+
234
+ const durationMs = Date.now() - startTime;
235
+
236
+ logTaskRun({
237
+ task_id: task.id,
238
+ run_at: new Date().toISOString(),
239
+ duration_ms: durationMs,
240
+ status: error ? 'error' : 'success',
241
+ result,
242
+ error,
243
+ });
244
+
245
+ const nextRun = computeNextRun(task);
246
+ const resultSummary = error
247
+ ? `Error: ${error}`
248
+ : result
249
+ ? result.slice(0, 200)
250
+ : 'Completed';
251
+ updateTaskAfterRun(task.id, nextRun, resultSummary);
252
+ }
253
+
254
+ let schedulerRunning = false;
255
+
256
+ export function startSchedulerLoop(deps: SchedulerDependencies): void {
257
+ if (schedulerRunning) {
258
+ logger.debug('Scheduler loop already running, skipping duplicate start');
259
+ return;
260
+ }
261
+ schedulerRunning = true;
262
+ logger.info('Scheduler loop started');
263
+
264
+ const loop = async () => {
265
+ try {
266
+ const dueTasks = getDueTasks();
267
+ if (dueTasks.length > 0) {
268
+ logger.info({ count: dueTasks.length }, 'Found due tasks');
269
+ }
270
+
271
+ for (const task of dueTasks) {
272
+ // Re-check task status in case it was paused/cancelled
273
+ const currentTask = getTaskById(task.id);
274
+ if (!currentTask || currentTask.status !== 'active') {
275
+ continue;
276
+ }
277
+
278
+ deps.queue.enqueueTask(currentTask.chat_jid, currentTask.id, () =>
279
+ runTask(currentTask, deps),
280
+ );
281
+ }
282
+ } catch (err) {
283
+ logger.error({ err }, 'Error in scheduler loop');
284
+ }
285
+
286
+ setTimeout(loop, SCHEDULER_POLL_INTERVAL);
287
+ };
288
+
289
+ loop();
290
+ }
291
+
292
+ /** @internal - for tests only. */
293
+ export function _resetSchedulerLoopForTests(): void {
294
+ schedulerRunning = false;
295
+ }
@@ -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,107 @@
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
+ }
94
+
95
+ // Callback type that channels use to deliver inbound messages
96
+ export type OnInboundMessage = (chatJid: string, message: NewMessage) => void;
97
+
98
+ // Callback for chat metadata discovery.
99
+ // name is optional — channels that deliver names inline (Telegram) pass it here;
100
+ // channels that sync names separately (via syncGroups) omit it.
101
+ export type OnChatMetadata = (
102
+ chatJid: string,
103
+ timestamp: string,
104
+ name?: string,
105
+ channel?: string,
106
+ isGroup?: boolean,
107
+ ) => 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
+ });