@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,397 @@
1
+ import fs from 'fs';
2
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
3
+
4
+ // Mock config before importing the module under test
5
+ vi.mock('./config.js', () => ({
6
+ DATA_DIR: '/tmp/nanoclaw-rc-test',
7
+ }));
8
+
9
+ // Mock child_process
10
+ const spawnMock = vi.fn();
11
+ vi.mock('child_process', () => ({
12
+ spawn: (...args: any[]) => spawnMock(...args),
13
+ }));
14
+
15
+ import {
16
+ startRemoteControl,
17
+ stopRemoteControl,
18
+ restoreRemoteControl,
19
+ getActiveSession,
20
+ _resetForTesting,
21
+ _getStateFilePath,
22
+ } from './remote-control.js';
23
+
24
+ // --- Helpers ---
25
+
26
+ function createMockProcess(pid = 12345) {
27
+ return {
28
+ pid,
29
+ unref: vi.fn(),
30
+ kill: vi.fn(),
31
+ stdin: { write: vi.fn(), end: vi.fn() },
32
+ };
33
+ }
34
+
35
+ describe('remote-control', () => {
36
+ const STATE_FILE = _getStateFilePath();
37
+ let readFileSyncSpy: ReturnType<typeof vi.spyOn>;
38
+ let writeFileSyncSpy: ReturnType<typeof vi.spyOn>;
39
+ let unlinkSyncSpy: ReturnType<typeof vi.spyOn>;
40
+ let mkdirSyncSpy: ReturnType<typeof vi.spyOn>;
41
+ let openSyncSpy: ReturnType<typeof vi.spyOn>;
42
+ let closeSyncSpy: ReturnType<typeof vi.spyOn>;
43
+
44
+ // Track what readFileSync should return for the stdout file
45
+ let stdoutFileContent: string;
46
+
47
+ beforeEach(() => {
48
+ _resetForTesting();
49
+ spawnMock.mockReset();
50
+ stdoutFileContent = '';
51
+
52
+ // Default fs mocks
53
+ mkdirSyncSpy = vi
54
+ .spyOn(fs, 'mkdirSync')
55
+ .mockImplementation(() => undefined as any);
56
+ writeFileSyncSpy = vi
57
+ .spyOn(fs, 'writeFileSync')
58
+ .mockImplementation(() => {});
59
+ unlinkSyncSpy = vi.spyOn(fs, 'unlinkSync').mockImplementation(() => {});
60
+ openSyncSpy = vi.spyOn(fs, 'openSync').mockReturnValue(42 as any);
61
+ closeSyncSpy = vi.spyOn(fs, 'closeSync').mockImplementation(() => {});
62
+
63
+ // readFileSync: return stdoutFileContent for the stdout file, state file, etc.
64
+ readFileSyncSpy = vi.spyOn(fs, 'readFileSync').mockImplementation(((
65
+ p: string,
66
+ ) => {
67
+ if (p.endsWith('remote-control.stdout')) return stdoutFileContent;
68
+ if (p.endsWith('remote-control.json')) {
69
+ throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
70
+ }
71
+ return '';
72
+ }) as any);
73
+ });
74
+
75
+ afterEach(() => {
76
+ _resetForTesting();
77
+ vi.restoreAllMocks();
78
+ });
79
+
80
+ // --- startRemoteControl ---
81
+
82
+ describe('startRemoteControl', () => {
83
+ it('spawns claude remote-control and returns the URL', async () => {
84
+ const proc = createMockProcess();
85
+ spawnMock.mockReturnValue(proc);
86
+
87
+ // Simulate URL appearing in stdout file on first poll
88
+ stdoutFileContent =
89
+ 'Session URL: https://claude.ai/code?bridge=env_abc123\n';
90
+ vi.spyOn(process, 'kill').mockImplementation((() => true) as any);
91
+
92
+ const result = await startRemoteControl('user1', 'tg:123', '/project');
93
+
94
+ expect(result).toEqual({
95
+ ok: true,
96
+ url: 'https://claude.ai/code?bridge=env_abc123',
97
+ });
98
+ expect(spawnMock).toHaveBeenCalledWith(
99
+ 'claude',
100
+ ['remote-control', '--name', 'NanoClaw Remote'],
101
+ expect.objectContaining({ cwd: '/project', detached: true }),
102
+ );
103
+ expect(proc.unref).toHaveBeenCalled();
104
+ });
105
+
106
+ it('uses file descriptors for stdout/stderr (not pipes)', async () => {
107
+ const proc = createMockProcess();
108
+ spawnMock.mockReturnValue(proc);
109
+ stdoutFileContent = 'https://claude.ai/code?bridge=env_test\n';
110
+ vi.spyOn(process, 'kill').mockImplementation((() => true) as any);
111
+
112
+ await startRemoteControl('user1', 'tg:123', '/project');
113
+
114
+ const spawnCall = spawnMock.mock.calls[0];
115
+ const options = spawnCall[2];
116
+ // stdio[0] is 'pipe' so we can write 'y' to accept the prompt
117
+ expect(options.stdio[0]).toBe('pipe');
118
+ expect(typeof options.stdio[1]).toBe('number');
119
+ expect(typeof options.stdio[2]).toBe('number');
120
+ });
121
+
122
+ it('closes file descriptors in parent after spawn', async () => {
123
+ const proc = createMockProcess();
124
+ spawnMock.mockReturnValue(proc);
125
+ stdoutFileContent = 'https://claude.ai/code?bridge=env_test\n';
126
+ vi.spyOn(process, 'kill').mockImplementation((() => true) as any);
127
+
128
+ await startRemoteControl('user1', 'tg:123', '/project');
129
+
130
+ // Two openSync calls (stdout + stderr), two closeSync calls
131
+ expect(openSyncSpy).toHaveBeenCalledTimes(2);
132
+ expect(closeSyncSpy).toHaveBeenCalledTimes(2);
133
+ });
134
+
135
+ it('saves state to disk after capturing URL', async () => {
136
+ const proc = createMockProcess(99999);
137
+ spawnMock.mockReturnValue(proc);
138
+ stdoutFileContent = 'https://claude.ai/code?bridge=env_save\n';
139
+ vi.spyOn(process, 'kill').mockImplementation((() => true) as any);
140
+
141
+ await startRemoteControl('user1', 'tg:123', '/project');
142
+
143
+ expect(writeFileSyncSpy).toHaveBeenCalledWith(
144
+ STATE_FILE,
145
+ expect.stringContaining('"pid":99999'),
146
+ );
147
+ });
148
+
149
+ it('returns existing URL if session is already active', async () => {
150
+ const proc = createMockProcess();
151
+ spawnMock.mockReturnValue(proc);
152
+ stdoutFileContent = 'https://claude.ai/code?bridge=env_existing\n';
153
+ vi.spyOn(process, 'kill').mockImplementation((() => true) as any);
154
+
155
+ await startRemoteControl('user1', 'tg:123', '/project');
156
+
157
+ // Second call should return existing URL without spawning
158
+ const result = await startRemoteControl('user2', 'tg:456', '/project');
159
+ expect(result).toEqual({
160
+ ok: true,
161
+ url: 'https://claude.ai/code?bridge=env_existing',
162
+ });
163
+ expect(spawnMock).toHaveBeenCalledTimes(1);
164
+ });
165
+
166
+ it('starts new session if existing process is dead', async () => {
167
+ const proc1 = createMockProcess(11111);
168
+ const proc2 = createMockProcess(22222);
169
+ spawnMock.mockReturnValueOnce(proc1).mockReturnValueOnce(proc2);
170
+
171
+ // First start: process alive, URL found
172
+ const killSpy = vi
173
+ .spyOn(process, 'kill')
174
+ .mockImplementation((() => true) as any);
175
+ stdoutFileContent = 'https://claude.ai/code?bridge=env_first\n';
176
+ await startRemoteControl('user1', 'tg:123', '/project');
177
+
178
+ // Old process (11111) is dead, new process (22222) is alive
179
+ killSpy.mockImplementation(((pid: number, sig: any) => {
180
+ if (pid === 11111 && (sig === 0 || sig === undefined)) {
181
+ throw new Error('ESRCH');
182
+ }
183
+ return true;
184
+ }) as any);
185
+
186
+ stdoutFileContent = 'https://claude.ai/code?bridge=env_second\n';
187
+ const result = await startRemoteControl('user1', 'tg:123', '/project');
188
+
189
+ expect(result).toEqual({
190
+ ok: true,
191
+ url: 'https://claude.ai/code?bridge=env_second',
192
+ });
193
+ expect(spawnMock).toHaveBeenCalledTimes(2);
194
+ });
195
+
196
+ it('returns error if process exits before URL', async () => {
197
+ const proc = createMockProcess(33333);
198
+ spawnMock.mockReturnValue(proc);
199
+ stdoutFileContent = '';
200
+
201
+ // Process is dead (poll will detect this)
202
+ vi.spyOn(process, 'kill').mockImplementation((() => {
203
+ throw new Error('ESRCH');
204
+ }) as any);
205
+
206
+ const result = await startRemoteControl('user1', 'tg:123', '/project');
207
+ expect(result).toEqual({
208
+ ok: false,
209
+ error: 'Process exited before producing URL',
210
+ });
211
+ });
212
+
213
+ it('times out if URL never appears', async () => {
214
+ vi.useFakeTimers();
215
+ const proc = createMockProcess(44444);
216
+ spawnMock.mockReturnValue(proc);
217
+ stdoutFileContent = 'no url here';
218
+ vi.spyOn(process, 'kill').mockImplementation((() => true) as any);
219
+
220
+ const promise = startRemoteControl('user1', 'tg:123', '/project');
221
+
222
+ // Advance past URL_TIMEOUT_MS (30s), with enough steps for polls
223
+ for (let i = 0; i < 160; i++) {
224
+ await vi.advanceTimersByTimeAsync(200);
225
+ }
226
+
227
+ const result = await promise;
228
+ expect(result).toEqual({
229
+ ok: false,
230
+ error: 'Timed out waiting for Remote Control URL',
231
+ });
232
+
233
+ vi.useRealTimers();
234
+ });
235
+
236
+ it('returns error if spawn throws', async () => {
237
+ spawnMock.mockImplementation(() => {
238
+ throw new Error('ENOENT');
239
+ });
240
+
241
+ const result = await startRemoteControl('user1', 'tg:123', '/project');
242
+ expect(result).toEqual({
243
+ ok: false,
244
+ error: 'Failed to start: ENOENT',
245
+ });
246
+ });
247
+ });
248
+
249
+ // --- stopRemoteControl ---
250
+
251
+ describe('stopRemoteControl', () => {
252
+ it('kills the process and clears state', async () => {
253
+ const proc = createMockProcess(55555);
254
+ spawnMock.mockReturnValue(proc);
255
+ stdoutFileContent = 'https://claude.ai/code?bridge=env_stop\n';
256
+ const killSpy = vi
257
+ .spyOn(process, 'kill')
258
+ .mockImplementation((() => true) as any);
259
+
260
+ await startRemoteControl('user1', 'tg:123', '/project');
261
+
262
+ const result = stopRemoteControl();
263
+ expect(result).toEqual({ ok: true });
264
+ expect(killSpy).toHaveBeenCalledWith(55555, 'SIGTERM');
265
+ expect(unlinkSyncSpy).toHaveBeenCalledWith(STATE_FILE);
266
+ expect(getActiveSession()).toBeNull();
267
+ });
268
+
269
+ it('returns error when no session is active', () => {
270
+ const result = stopRemoteControl();
271
+ expect(result).toEqual({
272
+ ok: false,
273
+ error: 'No active Remote Control session',
274
+ });
275
+ });
276
+ });
277
+
278
+ // --- restoreRemoteControl ---
279
+
280
+ describe('restoreRemoteControl', () => {
281
+ it('restores session if state file exists and process is alive', () => {
282
+ const session = {
283
+ pid: 77777,
284
+ url: 'https://claude.ai/code?bridge=env_restored',
285
+ startedBy: 'user1',
286
+ startedInChat: 'tg:123',
287
+ startedAt: '2026-01-01T00:00:00.000Z',
288
+ };
289
+ readFileSyncSpy.mockImplementation(((p: string) => {
290
+ if (p.endsWith('remote-control.json')) return JSON.stringify(session);
291
+ return '';
292
+ }) as any);
293
+ vi.spyOn(process, 'kill').mockImplementation((() => true) as any);
294
+
295
+ restoreRemoteControl();
296
+
297
+ const active = getActiveSession();
298
+ expect(active).not.toBeNull();
299
+ expect(active!.pid).toBe(77777);
300
+ expect(active!.url).toBe('https://claude.ai/code?bridge=env_restored');
301
+ });
302
+
303
+ it('clears state if process is dead', () => {
304
+ const session = {
305
+ pid: 88888,
306
+ url: 'https://claude.ai/code?bridge=env_dead',
307
+ startedBy: 'user1',
308
+ startedInChat: 'tg:123',
309
+ startedAt: '2026-01-01T00:00:00.000Z',
310
+ };
311
+ readFileSyncSpy.mockImplementation(((p: string) => {
312
+ if (p.endsWith('remote-control.json')) return JSON.stringify(session);
313
+ return '';
314
+ }) as any);
315
+ vi.spyOn(process, 'kill').mockImplementation((() => {
316
+ throw new Error('ESRCH');
317
+ }) as any);
318
+
319
+ restoreRemoteControl();
320
+
321
+ expect(getActiveSession()).toBeNull();
322
+ expect(unlinkSyncSpy).toHaveBeenCalled();
323
+ });
324
+
325
+ it('does nothing if no state file exists', () => {
326
+ // readFileSyncSpy default throws ENOENT for .json
327
+ restoreRemoteControl();
328
+ expect(getActiveSession()).toBeNull();
329
+ });
330
+
331
+ it('clears state on corrupted JSON', () => {
332
+ readFileSyncSpy.mockImplementation(((p: string) => {
333
+ if (p.endsWith('remote-control.json')) return 'not json{{{';
334
+ return '';
335
+ }) as any);
336
+
337
+ restoreRemoteControl();
338
+
339
+ expect(getActiveSession()).toBeNull();
340
+ expect(unlinkSyncSpy).toHaveBeenCalled();
341
+ });
342
+
343
+ // ** This is the key integration test: restore → stop must work **
344
+ it('stopRemoteControl works after restoreRemoteControl', () => {
345
+ const session = {
346
+ pid: 77777,
347
+ url: 'https://claude.ai/code?bridge=env_restored',
348
+ startedBy: 'user1',
349
+ startedInChat: 'tg:123',
350
+ startedAt: '2026-01-01T00:00:00.000Z',
351
+ };
352
+ readFileSyncSpy.mockImplementation(((p: string) => {
353
+ if (p.endsWith('remote-control.json')) return JSON.stringify(session);
354
+ return '';
355
+ }) as any);
356
+ const killSpy = vi
357
+ .spyOn(process, 'kill')
358
+ .mockImplementation((() => true) as any);
359
+
360
+ restoreRemoteControl();
361
+ expect(getActiveSession()).not.toBeNull();
362
+
363
+ const result = stopRemoteControl();
364
+ expect(result).toEqual({ ok: true });
365
+ expect(killSpy).toHaveBeenCalledWith(77777, 'SIGTERM');
366
+ expect(unlinkSyncSpy).toHaveBeenCalled();
367
+ expect(getActiveSession()).toBeNull();
368
+ });
369
+
370
+ it('startRemoteControl returns restored URL without spawning', () => {
371
+ const session = {
372
+ pid: 77777,
373
+ url: 'https://claude.ai/code?bridge=env_restored',
374
+ startedBy: 'user1',
375
+ startedInChat: 'tg:123',
376
+ startedAt: '2026-01-01T00:00:00.000Z',
377
+ };
378
+ readFileSyncSpy.mockImplementation(((p: string) => {
379
+ if (p.endsWith('remote-control.json')) return JSON.stringify(session);
380
+ return '';
381
+ }) as any);
382
+ vi.spyOn(process, 'kill').mockImplementation((() => true) as any);
383
+
384
+ restoreRemoteControl();
385
+
386
+ return startRemoteControl('user2', 'tg:456', '/project').then(
387
+ (result) => {
388
+ expect(result).toEqual({
389
+ ok: true,
390
+ url: 'https://claude.ai/code?bridge=env_restored',
391
+ });
392
+ expect(spawnMock).not.toHaveBeenCalled();
393
+ },
394
+ );
395
+ });
396
+ });
397
+ });
@@ -0,0 +1,224 @@
1
+ import { spawn } from 'child_process';
2
+ import fs from 'fs';
3
+ import path from 'path';
4
+
5
+ import { DATA_DIR } from './config.js';
6
+ import { logger } from './logger.js';
7
+
8
+ interface RemoteControlSession {
9
+ pid: number;
10
+ url: string;
11
+ startedBy: string;
12
+ startedInChat: string;
13
+ startedAt: string;
14
+ }
15
+
16
+ let activeSession: RemoteControlSession | null = null;
17
+
18
+ const URL_REGEX = /https:\/\/claude\.ai\/code\S+/;
19
+ const URL_TIMEOUT_MS = 30_000;
20
+ const URL_POLL_MS = 200;
21
+ const STATE_FILE = path.join(DATA_DIR, 'remote-control.json');
22
+ const STDOUT_FILE = path.join(DATA_DIR, 'remote-control.stdout');
23
+ const STDERR_FILE = path.join(DATA_DIR, 'remote-control.stderr');
24
+
25
+ function saveState(session: RemoteControlSession): void {
26
+ fs.mkdirSync(path.dirname(STATE_FILE), { recursive: true });
27
+ fs.writeFileSync(STATE_FILE, JSON.stringify(session));
28
+ }
29
+
30
+ function clearState(): void {
31
+ try {
32
+ fs.unlinkSync(STATE_FILE);
33
+ } catch {
34
+ // ignore
35
+ }
36
+ }
37
+
38
+ function isProcessAlive(pid: number): boolean {
39
+ try {
40
+ process.kill(pid, 0);
41
+ return true;
42
+ } catch {
43
+ return false;
44
+ }
45
+ }
46
+
47
+ /**
48
+ * Restore session from disk on startup.
49
+ * If the process is still alive, adopt it. Otherwise, clean up.
50
+ */
51
+ export function restoreRemoteControl(): void {
52
+ let data: string;
53
+ try {
54
+ data = fs.readFileSync(STATE_FILE, 'utf-8');
55
+ } catch {
56
+ return;
57
+ }
58
+
59
+ try {
60
+ const session: RemoteControlSession = JSON.parse(data);
61
+ if (session.pid && isProcessAlive(session.pid)) {
62
+ activeSession = session;
63
+ logger.info(
64
+ { pid: session.pid, url: session.url },
65
+ 'Restored Remote Control session from previous run',
66
+ );
67
+ } else {
68
+ clearState();
69
+ }
70
+ } catch {
71
+ clearState();
72
+ }
73
+ }
74
+
75
+ export function getActiveSession(): RemoteControlSession | null {
76
+ return activeSession;
77
+ }
78
+
79
+ /** @internal — exported for testing only */
80
+ export function _resetForTesting(): void {
81
+ activeSession = null;
82
+ }
83
+
84
+ /** @internal — exported for testing only */
85
+ export function _getStateFilePath(): string {
86
+ return STATE_FILE;
87
+ }
88
+
89
+ export async function startRemoteControl(
90
+ sender: string,
91
+ chatJid: string,
92
+ cwd: string,
93
+ ): Promise<{ ok: true; url: string } | { ok: false; error: string }> {
94
+ if (activeSession) {
95
+ // Verify the process is still alive
96
+ if (isProcessAlive(activeSession.pid)) {
97
+ return { ok: true, url: activeSession.url };
98
+ }
99
+ // Process died — clean up and start a new one
100
+ activeSession = null;
101
+ clearState();
102
+ }
103
+
104
+ // Redirect stdout/stderr to files so the process has no pipes to the parent.
105
+ // This prevents SIGPIPE when NanoClaw restarts.
106
+ fs.mkdirSync(DATA_DIR, { recursive: true });
107
+ const stdoutFd = fs.openSync(STDOUT_FILE, 'w');
108
+ const stderrFd = fs.openSync(STDERR_FILE, 'w');
109
+
110
+ let proc;
111
+ try {
112
+ proc = spawn('claude', ['remote-control', '--name', 'NanoClaw Remote'], {
113
+ cwd,
114
+ stdio: ['pipe', stdoutFd, stderrFd],
115
+ detached: true,
116
+ });
117
+ } catch (err: any) {
118
+ fs.closeSync(stdoutFd);
119
+ fs.closeSync(stderrFd);
120
+ return { ok: false, error: `Failed to start: ${err.message}` };
121
+ }
122
+
123
+ // Auto-accept the "Enable Remote Control?" prompt
124
+ if (proc.stdin) {
125
+ proc.stdin.write('y\n');
126
+ proc.stdin.end();
127
+ }
128
+
129
+ // Close FDs in the parent — the child inherited copies
130
+ fs.closeSync(stdoutFd);
131
+ fs.closeSync(stderrFd);
132
+
133
+ // Fully detach from parent
134
+ proc.unref();
135
+
136
+ const pid = proc.pid;
137
+ if (!pid) {
138
+ return { ok: false, error: 'Failed to get process PID' };
139
+ }
140
+
141
+ // Poll the stdout file for the URL
142
+ return new Promise((resolve) => {
143
+ const startTime = Date.now();
144
+
145
+ const poll = () => {
146
+ // Check if process died
147
+ if (!isProcessAlive(pid)) {
148
+ resolve({ ok: false, error: 'Process exited before producing URL' });
149
+ return;
150
+ }
151
+
152
+ // Check for URL in stdout file
153
+ let content = '';
154
+ try {
155
+ content = fs.readFileSync(STDOUT_FILE, 'utf-8');
156
+ } catch {
157
+ // File might not have content yet
158
+ }
159
+
160
+ const match = content.match(URL_REGEX);
161
+ if (match) {
162
+ const session: RemoteControlSession = {
163
+ pid,
164
+ url: match[0],
165
+ startedBy: sender,
166
+ startedInChat: chatJid,
167
+ startedAt: new Date().toISOString(),
168
+ };
169
+ activeSession = session;
170
+ saveState(session);
171
+
172
+ logger.info(
173
+ { url: match[0], pid, sender, chatJid },
174
+ 'Remote Control session started',
175
+ );
176
+ resolve({ ok: true, url: match[0] });
177
+ return;
178
+ }
179
+
180
+ // Timeout check
181
+ if (Date.now() - startTime >= URL_TIMEOUT_MS) {
182
+ try {
183
+ process.kill(-pid, 'SIGTERM');
184
+ } catch {
185
+ try {
186
+ process.kill(pid, 'SIGTERM');
187
+ } catch {
188
+ // already dead
189
+ }
190
+ }
191
+ resolve({
192
+ ok: false,
193
+ error: 'Timed out waiting for Remote Control URL',
194
+ });
195
+ return;
196
+ }
197
+
198
+ setTimeout(poll, URL_POLL_MS);
199
+ };
200
+
201
+ poll();
202
+ });
203
+ }
204
+
205
+ export function stopRemoteControl():
206
+ | {
207
+ ok: true;
208
+ }
209
+ | { ok: false; error: string } {
210
+ if (!activeSession) {
211
+ return { ok: false, error: 'No active Remote Control session' };
212
+ }
213
+
214
+ const { pid } = activeSession;
215
+ try {
216
+ process.kill(pid, 'SIGTERM');
217
+ } catch {
218
+ // already dead
219
+ }
220
+ activeSession = null;
221
+ clearState();
222
+ logger.info({ pid }, 'Remote Control session stopped');
223
+ return { ok: true };
224
+ }
package/src/router.ts ADDED
@@ -0,0 +1,52 @@
1
+ import { Channel, NewMessage } from './types.js';
2
+ import { formatLocalTime } from './timezone.js';
3
+
4
+ export function escapeXml(s: string): string {
5
+ if (!s) return '';
6
+ return s
7
+ .replace(/&/g, '&amp;')
8
+ .replace(/</g, '&lt;')
9
+ .replace(/>/g, '&gt;')
10
+ .replace(/"/g, '&quot;');
11
+ }
12
+
13
+ export function formatMessages(
14
+ messages: NewMessage[],
15
+ timezone: string,
16
+ ): string {
17
+ const lines = messages.map((m) => {
18
+ const displayTime = formatLocalTime(m.timestamp, timezone);
19
+ return `<message sender="${escapeXml(m.sender_name)}" time="${escapeXml(displayTime)}">${escapeXml(m.content)}</message>`;
20
+ });
21
+
22
+ const header = `<context timezone="${escapeXml(timezone)}" />\n`;
23
+
24
+ return `${header}<messages>\n${lines.join('\n')}\n</messages>`;
25
+ }
26
+
27
+ export function stripInternalTags(text: string): string {
28
+ return text.replace(/<internal>[\s\S]*?<\/internal>/g, '').trim();
29
+ }
30
+
31
+ export function formatOutbound(rawText: string): string {
32
+ const text = stripInternalTags(rawText);
33
+ if (!text) return '';
34
+ return text;
35
+ }
36
+
37
+ export function routeOutbound(
38
+ channels: Channel[],
39
+ jid: string,
40
+ text: string,
41
+ ): Promise<void> {
42
+ const channel = channels.find((c) => c.ownsJid(jid) && c.isConnected());
43
+ if (!channel) throw new Error(`No channel for JID: ${jid}`);
44
+ return channel.sendMessage(jid, text);
45
+ }
46
+
47
+ export function findChannel(
48
+ channels: Channel[],
49
+ jid: string,
50
+ ): Channel | undefined {
51
+ return channels.find((c) => c.ownsJid(jid));
52
+ }