@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,1843 @@
1
+ import fs from 'fs';
2
+ import http from 'http';
3
+ import os from 'os';
4
+ import path from 'path';
5
+ import { logger } from '../logger.js';
6
+ import { registerChannel } from './registry.js';
7
+ import { getAllChats, getConversation, storeMessage, storeChatMetadata, updateChatName, updateChatCwd, deleteChat, clearChatMessages, deleteMessage, getWebSessionOrder, setWebSessionOrder, } from '../db.js';
8
+ import { DATA_DIR, GROUPS_DIR } from '../config.js';
9
+ // NANOCLAW_PORT/HOST/TOKEN are the canonical env var names; WEB_CHANNEL_* kept for backward compat.
10
+ const PORT = parseInt(process.env.NANOCLAW_PORT || process.env.WEB_CHANNEL_PORT || '3099', 10);
11
+ const HOST = process.env.NANOCLAW_HOST || process.env.WEB_CHANNEL_HOST || '127.0.0.1';
12
+ /** Optional access token for the web interface. Empty string = no protection. */
13
+ const TOKEN = process.env.NANOCLAW_TOKEN || '';
14
+ const WEB_JID_PREFIX = 'local@web-';
15
+ const GROUP_FOLDER = 'main';
16
+ const CRON_GROUP_FOLDER = 'web-cron'; // separate folder so cron container gets its own IPC directory
17
+ const GROUP_NAME = 'Web Chat';
18
+ const CRON_SESSION_ID = 'cron';
19
+ const CRON_SESSION_NAME = 'Cron Jobs';
20
+ const MAX_BODY_SIZE = 1 * 1024 * 1024; // 1 MB — default for all POST bodies
21
+ const MAX_UPLOAD_BODY_SIZE = 10 * 1024 * 1024; // 10 MB — for /upload (Base64 file data)
22
+ // Per-session SSE clients and ephemeral UI state
23
+ const sseClients = new Map();
24
+ const sessionCwds = new Map();
25
+ const sessionTyping = new Map(); // true while agent is processing
26
+ const sessionStatus = new Map(); // last status SSE payload (or 'null')
27
+ const registeredSessions = new Set();
28
+ /**
29
+ * Sanitize a session name:
30
+ * - removes Unicode control characters (Cc: U+0000–U+001F, U+007F–U+009F)
31
+ * - trims leading/trailing whitespace
32
+ * - limits to 256 characters
33
+ * Returns null if the result is empty (name should be rejected).
34
+ */
35
+ function sanitizeSessionName(raw) {
36
+ const cleaned = raw
37
+ .replace(/\p{Cc}/gu, '')
38
+ .trim()
39
+ .slice(0, 256);
40
+ return cleaned || null;
41
+ }
42
+ /** Parse the Cookie request header into a key→value map. */
43
+ function parseCookies(req) {
44
+ const header = req.headers.cookie ?? '';
45
+ return Object.fromEntries(header.split(';').flatMap((part) => {
46
+ const eq = part.indexOf('=');
47
+ if (eq < 1)
48
+ return [];
49
+ const k = part.slice(0, eq).trim();
50
+ const v = part.slice(eq + 1).trim();
51
+ try {
52
+ return [[k, decodeURIComponent(v)]];
53
+ }
54
+ catch {
55
+ return [[k, v]];
56
+ }
57
+ }));
58
+ }
59
+ /**
60
+ * Authorize the request and optionally upgrade to a session cookie.
61
+ *
62
+ * - No TOKEN configured → always returns true (no-op).
63
+ * - Token matches via ?token= query param → sets HttpOnly cookie so subsequent
64
+ * requests (SSE, API calls) are automatically authorized without repeating the token.
65
+ * - Token does not match → sends 401 and returns false.
66
+ *
67
+ * Accepted token locations (checked in order):
68
+ * 1. Cookie nanoclaw_token=<token>
69
+ * 2. Authorization Bearer <token>
70
+ * 3. Query param ?token=<value> (also upgrades to cookie on match)
71
+ *
72
+ * Must be called before response headers are written. If it returns false the
73
+ * response has already been sent — return from the caller immediately.
74
+ */
75
+ function authorizeRequest(req, res) {
76
+ if (!TOKEN)
77
+ return true;
78
+ const cookies = parseCookies(req);
79
+ if (cookies['nanoclaw_token'] === TOKEN)
80
+ return true;
81
+ const auth = req.headers['authorization'] ?? '';
82
+ if (auth.startsWith('Bearer ') && auth.slice(7) === TOKEN)
83
+ return true;
84
+ // Parse ?token= once — used for both the auth check and the cookie upgrade.
85
+ const raw = req.url?.match(/[?&]token=([^&]*)/)?.[1];
86
+ if (raw !== undefined) {
87
+ try {
88
+ if (decodeURIComponent(raw) === TOKEN) {
89
+ res.setHeader('Set-Cookie', `nanoclaw_token=${TOKEN}; HttpOnly; SameSite=Strict; Path=/`);
90
+ return true;
91
+ }
92
+ }
93
+ catch {
94
+ /* ignore malformed param */
95
+ }
96
+ }
97
+ res.writeHead(401, {
98
+ 'Content-Type': 'application/json',
99
+ 'WWW-Authenticate': 'Bearer realm="NanoClaw"',
100
+ });
101
+ res.end('{"error":"Unauthorized"}');
102
+ return false;
103
+ }
104
+ /**
105
+ * Collect the full POST body, enforcing a size limit.
106
+ * Sends 413 and returns early if the body exceeds maxSize.
107
+ * Calls callback(body) once the full body has been read.
108
+ *
109
+ * @param maxSize - byte limit (defaults to MAX_BODY_SIZE = 1 MB)
110
+ */
111
+ function collectBody(req, res, callback, maxSize = MAX_BODY_SIZE) {
112
+ let body = '';
113
+ let tooLarge = false;
114
+ req.on('data', (chunk) => {
115
+ if (tooLarge)
116
+ return;
117
+ body += chunk;
118
+ if (body.length > maxSize) {
119
+ tooLarge = true;
120
+ res.writeHead(413, { 'Content-Type': 'application/json' });
121
+ res.end('{"error":"Request too large"}');
122
+ req.resume(); // drain so the socket can close cleanly
123
+ }
124
+ });
125
+ req.on('end', () => {
126
+ if (tooLarge)
127
+ return;
128
+ callback(body);
129
+ });
130
+ req.on('error', () => {
131
+ tooLarge = true; // prevent callback from firing after a request error
132
+ if (!res.headersSent) {
133
+ res.writeHead(400);
134
+ res.end('Bad request');
135
+ }
136
+ });
137
+ }
138
+ /** Resolve and clamp a raw CWD value to within the workspace root. Returns '' on escape attempt. */
139
+ function sanitizeCwd(rawCwd) {
140
+ if (!rawCwd)
141
+ return '';
142
+ const workspace = process.cwd();
143
+ const resolved = path.resolve(workspace, rawCwd);
144
+ if (resolved === workspace)
145
+ return '';
146
+ if (!resolved.startsWith(workspace + path.sep))
147
+ return ''; // escape attempt
148
+ return path.relative(workspace, resolved);
149
+ }
150
+ /** Set CWD in the in-memory cache AND persist it to the DB. */
151
+ function setCwd(sessionId, rawCwd) {
152
+ const cwd = sanitizeCwd(rawCwd);
153
+ sessionCwds.set(sessionId, cwd);
154
+ try {
155
+ updateChatCwd(WEB_JID_PREFIX + sessionId, cwd);
156
+ }
157
+ catch { }
158
+ }
159
+ function getOrCreateClientSet(sessionId) {
160
+ if (!sseClients.has(sessionId))
161
+ sseClients.set(sessionId, new Set());
162
+ return sseClients.get(sessionId);
163
+ }
164
+ function broadcastToSession(sessionId, event, data) {
165
+ const clients = sseClients.get(sessionId);
166
+ if (!clients)
167
+ return;
168
+ const payload = `event: ${event}\ndata: ${data}\n\n`;
169
+ for (const client of clients) {
170
+ try {
171
+ client.write(payload);
172
+ }
173
+ catch {
174
+ clients.delete(client);
175
+ }
176
+ }
177
+ }
178
+ function sessionIdFromJid(jid) {
179
+ return jid.startsWith(WEB_JID_PREFIX)
180
+ ? jid.slice(WEB_JID_PREFIX.length)
181
+ : jid;
182
+ }
183
+ function sidFromUrl(url) {
184
+ const raw = url?.match(/[?&]sid=([^&]+)/)?.[1] ?? 'default';
185
+ // Sanitize: only alphanumeric, hyphens and underscores, max 64 chars
186
+ return raw.replace(/[^a-zA-Z0-9_-]/g, '').slice(0, 64) || 'default';
187
+ }
188
+ function getLocalIp() {
189
+ for (const ifaces of Object.values(os.networkInterfaces())) {
190
+ for (const iface of ifaces ?? []) {
191
+ if (iface.family === 'IPv4' && !iface.internal)
192
+ return iface.address;
193
+ }
194
+ }
195
+ return '127.0.0.1';
196
+ }
197
+ const HTML = `<!DOCTYPE html>
198
+ <html lang="en">
199
+ <head>
200
+ <meta charset="UTF-8">
201
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
202
+ <title>NanoClaw</title>
203
+ <link rel="icon" type="image/png" href="/favicon.png">
204
+ <link rel="icon" href="/favicon.ico" sizes="any">
205
+ <link rel="apple-touch-icon" href="/apple-touch-icon.png">
206
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.css">
207
+ <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github.min.css">
208
+ <style>
209
+ * { box-sizing: border-box; margin: 0; padding: 0; }
210
+ html, body { height: 100%; }
211
+ body { font-family: system-ui, sans-serif; background: #f5f5f5; color: #1a1a1a; display: flex; flex-direction: column; overflow: hidden; }
212
+
213
+ /* Header */
214
+ #header { background: #fff; padding: 10px 16px; border-bottom: 1px solid #e0e0e0; display: flex; align-items: center; gap: 10px; font-weight: 600; font-size: 16px; flex-shrink: 0; }
215
+ #toggle-sidebar { background: none; border: none; cursor: pointer; font-size: 20px; color: #555; padding: 2px 6px; border-radius: 4px; line-height: 1; }
216
+ #toggle-sidebar:hover { background: #f0f0f0; }
217
+ #header-title { flex-shrink: 0; white-space: nowrap; }
218
+ #header-cwd { flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: #888; font-weight: normal; font-size: 14px; min-width: 0; font-family: monospace; }
219
+
220
+ /* Main area */
221
+ #main-area { display: flex; flex: 1; overflow: hidden; }
222
+
223
+ /* Sidebar */
224
+ #sidebar { width: 220px; min-width: 220px; background: #fff; border-right: 1px solid #e0e0e0; display: flex; flex-direction: column; transition: width 0.2s, min-width 0.2s; overflow: hidden; }
225
+ #sidebar.collapsed { width: 0; min-width: 0; }
226
+ #sidebar-header { padding: 10px 12px; border-bottom: 1px solid #e0e0e0; display: flex; align-items: center; justify-content: space-between; flex-shrink: 0; }
227
+ #sidebar-title { font-weight: 600; font-size: 13px; color: #555; text-transform: uppercase; letter-spacing: 0.05em; }
228
+ #new-session-btn { background: none; border: 1px solid #d0d0d0; color: #555; width: 24px; height: 24px; border-radius: 4px; cursor: pointer; font-size: 18px; display: flex; align-items: center; justify-content: center; line-height: 1; flex-shrink: 0; }
229
+ #new-session-btn:hover { background: #f0f0f0; border-color: #aaa; }
230
+ #session-list { flex: 1; overflow-y: auto; padding: 6px; display: flex; flex-direction: column; gap: 2px; }
231
+ .session-item { display: flex; align-items: center; padding: 7px 8px; border-radius: 6px; cursor: pointer; gap: 6px; min-width: 0; }
232
+ .session-item:hover { background: #f5f5f5; }
233
+ .session-item.active { background: #eff6ff; }
234
+ .session-name { flex: 1; font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: #1a1a1a; }
235
+ .session-item.active .session-name { color: #2563eb; font-weight: 500; }
236
+ .session-item.unread .session-name { font-weight: 600; }
237
+ .session-unread-dot { width: 7px; height: 7px; border-radius: 50%; background: #2563eb; flex-shrink: 0; display: none; }
238
+ .session-item.unread .session-unread-dot { display: block; }
239
+ .session-name-input { flex: 1; font-size: 13px; border: 1px solid #2563eb; border-radius: 3px; padding: 1px 4px; outline: none; min-width: 0; }
240
+ .session-actions { display: none; align-items: center; gap: 1px; flex-shrink: 0; }
241
+ .session-item:hover .session-actions { display: flex; }
242
+ .session-btn { background: none; border: none; cursor: pointer; color: #999; padding: 2px 3px; border-radius: 3px; font-size: 13px; line-height: 1; }
243
+ .session-btn:hover { background: #e0e0e0; color: #333; }
244
+
245
+ /* Messages */
246
+ #messages { flex: 1; overflow-y: auto; padding: 16px; display: flex; flex-direction: column; gap: 12px; }
247
+ .msg-row { display: flex; align-items: flex-start; gap: 5px; }
248
+ .msg-row.bot { justify-content: flex-start; }
249
+ .msg-row.user { justify-content: flex-end; }
250
+ .msg-row.user .del-btn { order: -1; }
251
+ .del-btn { flex-shrink: 0; background: none; border: none; cursor: pointer; color: #ccc; padding: 2px 3px; line-height: 1; border-radius: 4px; transition: color 0.15s; margin-top: 7px; }
252
+ .del-btn:hover { color: #ef4444; }
253
+ .msg { max-width: 75%; padding: 10px 14px; border-radius: 12px; line-height: 1.6; word-break: break-word; font-size: 15px; }
254
+ .msg.user { background: #2563eb; color: #fff; border-bottom-right-radius: 4px; white-space: pre-wrap; }
255
+ .msg.bot { background: #ffffff; border: 1px solid #e0e0e0; border-bottom-left-radius: 4px; position: relative; }
256
+ .msg.typing { color: #888; font-style: italic; }
257
+ .msg.status { color: #999; font-size: 12px; font-style: italic; background: transparent; border: none; padding: 2px 0; max-width: 100%; }
258
+ .msg.bot p { margin: 0.4em 0; }
259
+ .msg.bot p:first-child { margin-top: 0; }
260
+ .msg.bot p:last-child { margin-bottom: 0; }
261
+ .msg.bot ul, .msg.bot ol { padding-left: 1.5em; margin: 0.4em 0; }
262
+ .msg.bot li { margin: 0.2em 0; }
263
+ .msg.bot h1, .msg.bot h2, .msg.bot h3, .msg.bot h4 { margin: 0.6em 0 0.3em; line-height: 1.3; }
264
+ .msg.bot pre { background: #f6f8fa; border: 1px solid #e0e0e0; border-radius: 6px; padding: 10px 14px; overflow-x: auto; margin: 0.6em 0; white-space: pre; position: relative; }
265
+ .copy-btn { position: absolute; top: 6px; right: 6px; background: none; border: none; border-radius: 4px; padding: 2px 4px; cursor: pointer; color: #bbb; transition: color 0.15s; line-height: 1; }
266
+ .copy-btn:hover { color: #555; }
267
+ .copy-btn.copied { color: #16a34a; }
268
+ .msg.bot code { background: #f0f2f4; padding: 2px 5px; border-radius: 4px; font-size: 0.88em; font-family: monospace; }
269
+ .msg.bot pre code { background: none; padding: 0; font-size: 0.88em; }
270
+ .msg.bot blockquote { border-left: 3px solid #d0d0d0; padding-left: 12px; color: #666; margin: 0.5em 0; }
271
+ .msg.bot table { border-collapse: collapse; margin: 0.6em 0; }
272
+ .msg.bot th, .msg.bot td { border: 1px solid #e0e0e0; padding: 6px 12px; }
273
+ .msg.bot th { background: #f0f2f4; font-weight: 600; }
274
+ .msg.bot a { color: #2563eb; text-decoration: underline; }
275
+ .msg.bot hr { border: none; border-top: 1px solid #e0e0e0; margin: 0.6em 0; }
276
+
277
+ /* Input */
278
+ #input-area { display: flex; gap: 8px; padding: 12px; border-top: 1px solid #e0e0e0; background: #f5f5f5; flex-shrink: 0; }
279
+ #input { flex: 1; background: #fff; border: 1px solid #d0d0d0; color: #1a1a1a; padding: 10px 14px; border-radius: 8px; font-size: 15px; outline: none; resize: none; max-height: 120px; }
280
+ #input:focus { border-color: #2563eb; }
281
+ #send { background: #2563eb; color: #fff; border: none; padding: 10px 18px; border-radius: 8px; cursor: pointer; font-size: 15px; }
282
+ #send:hover { background: #1d4ed8; }
283
+ #send:disabled { background: #aaa; cursor: default; }
284
+ #cancel-btn { background: #ef4444; color: #fff; border: none; padding: 10px 14px; border-radius: 8px; cursor: pointer; font-size: 15px; display: none; }
285
+ #cancel-btn:hover { background: #dc2626; }
286
+
287
+ /* Drop overlay */
288
+ #drop-overlay { display:none; position:fixed; inset:0; background:rgba(74,144,217,0.15); border:3px dashed #4a90d9; z-index:100; align-items:center; justify-content:center; font-size:24px; color:#4a90d9; pointer-events:none; }
289
+ #drop-overlay.active { display:flex; }
290
+
291
+ /* Connection indicator dot */
292
+ #conn-dot { width: 9px; height: 9px; border-radius: 50%; flex-shrink: 0; background: #ccc; transition: background 0.4s; }
293
+ #conn-dot.connected { background: #22c55e; }
294
+ #conn-dot.connecting { background: #f59e0b; animation: pulse-dot 1.2s ease-in-out infinite; }
295
+ #conn-dot.disconnected { background: #ef4444; }
296
+ @keyframes pulse-dot { 0%,100% { opacity:1; } 50% { opacity:0.35; } }
297
+
298
+ /* Session drag-and-drop */
299
+ .session-item.drag-src { opacity: 0.45; }
300
+ .session-item.drag-over { border-top: 2px solid #2563eb; margin-top: -2px; }
301
+
302
+ /* Mobile: sidebar as overlay so it doesn't squish the chat area */
303
+ @media (max-width: 640px) {
304
+ #main-area { position: relative; }
305
+ #sidebar { position: absolute; top: 0; left: 0; height: 100%; z-index: 50; box-shadow: 2px 0 8px rgba(0,0,0,0.18); }
306
+ }
307
+ /* Backdrop sits behind the sidebar but above the chat — JS controls display */
308
+ #sidebar-backdrop { display: none; position: absolute; inset: 0; z-index: 49; background: transparent; }
309
+ </style>
310
+ </head>
311
+ <body>
312
+ <div id="drop-overlay">Dateien hier ablegen</div>
313
+
314
+ <div id="header">
315
+ <button id="toggle-sidebar" title="Sidebar ein-/ausblenden">☰</button>
316
+ <span id="header-title">NanoClaw — __SERVER_ADDRESS__</span>
317
+ <span id="header-cwd"></span>
318
+ <span id="conn-dot" title="Verbindet…"></span>
319
+ </div>
320
+
321
+ <div id="main-area">
322
+ <div id="sidebar-backdrop"></div>
323
+ <div id="sidebar">
324
+ <div id="sidebar-header">
325
+ <span id="sidebar-title">Chats</span>
326
+ <button id="new-session-btn" title="Neuen Chat starten">+</button>
327
+ </div>
328
+ <div id="session-list"></div>
329
+ </div>
330
+ <div id="messages"></div>
331
+ </div>
332
+
333
+ <div id="input-area">
334
+ <textarea id="input" rows="1" placeholder="Type a message…"></textarea>
335
+ <button id="cancel-btn" title="Anfrage abbrechen">✕</button>
336
+ <button id="send">Send</button>
337
+ </div>
338
+
339
+ <script src="https://cdn.jsdelivr.net/npm/marked@12/marked.min.js"></script>
340
+ <script src="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.js"></script>
341
+ <script src="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/contrib/auto-render.min.js"></script>
342
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
343
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/languages/typescript.min.js"></script>
344
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/languages/java.min.js"></script>
345
+ <script src="https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js"></script>
346
+ <script>
347
+ // ── Markdown renderer ──────────────────────────────────────────────────
348
+ const renderer = {
349
+ code(code, lang) {
350
+ if (lang === 'mermaid') return '<pre class="mermaid">' + code + '</pre>';
351
+ const language = (lang && hljs.getLanguage(lang)) ? lang : 'plaintext';
352
+ return '<pre><code class="hljs language-' + language + '">' +
353
+ hljs.highlight(code, { language }).value + '</code></pre>';
354
+ }
355
+ };
356
+ marked.use({ renderer, gfm: true, breaks: true });
357
+ mermaid.initialize({ startOnLoad: false, theme: 'default' });
358
+
359
+ // ── UUID helper (works in non-secure contexts, e.g. http:// on Android) ──
360
+ function uuid() {
361
+ if (typeof crypto !== 'undefined' && crypto.randomUUID) return crypto.randomUUID();
362
+ return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
363
+ const r = Math.random() * 16 | 0;
364
+ return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16);
365
+ });
366
+ }
367
+
368
+ // ── Session storage helpers ────────────────────────────────────────────
369
+ function loadSessions() {
370
+ try {
371
+ const list = JSON.parse(localStorage.getItem('sessions') || '[]');
372
+ // Repair sessions with missing names (defensive, handles stale/corrupted data)
373
+ let repaired = false;
374
+ for (const s of list) {
375
+ if (!s.name) {
376
+ s.name = sessionLabel(new Date(s.createdAt || Date.now()));
377
+ // Use old timestamp (createdAt) so server wins on next merge if it has a real name
378
+ s.nameUpdatedAt = s.createdAt || 0;
379
+ repaired = true;
380
+ }
381
+ }
382
+ if (repaired) localStorage.setItem('sessions', JSON.stringify(list));
383
+ return list;
384
+ } catch { return []; }
385
+ }
386
+ function saveSessions(list) { localStorage.setItem('sessions', JSON.stringify(list)); }
387
+ function sessionLabel(date) {
388
+ if (!date || isNaN(date.getTime())) date = new Date(); // guard against Invalid Date
389
+ try {
390
+ const s = date.toLocaleString('de-DE', { day:'2-digit', month:'2-digit', year:'2-digit', hour:'2-digit', minute:'2-digit' });
391
+ if (s && s.length > 3) return s;
392
+ } catch {}
393
+ // Fallback for environments where Intl/de-DE is unavailable (e.g. some Android WebViews)
394
+ const p = n => String(n).padStart(2, '0');
395
+ return p(date.getDate()) + '.' + p(date.getMonth()+1) + '.' + String(date.getFullYear()).slice(2)
396
+ + ', ' + p(date.getHours()) + ':' + p(date.getMinutes());
397
+ }
398
+ function saveCwd(sid, cwd) { localStorage.setItem('cwd:' + sid, cwd); }
399
+ function loadCwd(sid) { return localStorage.getItem('cwd:' + sid) || ''; }
400
+
401
+ function ensureSessionInList(sid) {
402
+ const list = loadSessions();
403
+ const existing = list.find(s => s.id === sid);
404
+ if (!existing) {
405
+ const now = Date.now();
406
+ list.unshift({ id: sid, name: sessionLabel(new Date()), createdAt: now, isOwn: true, nameUpdatedAt: now });
407
+ saveSessions(list);
408
+ return true; // newly created
409
+ } else {
410
+ // Fix missing isOwn flag or empty name from previous versions
411
+ let changed = false;
412
+ if (!existing.isOwn) { existing.isOwn = true; changed = true; }
413
+ if (!existing.name) {
414
+ existing.name = sessionLabel(new Date(existing.createdAt || Date.now()));
415
+ // Use old timestamp so server wins on merge if it has a real name
416
+ existing.nameUpdatedAt = existing.createdAt || 0;
417
+ changed = true;
418
+ }
419
+ if (changed) saveSessions(list);
420
+ return false; // already existed
421
+ }
422
+ }
423
+
424
+ // ── Message history (per session, in localStorage) ─────────────────────
425
+ const MAX_HISTORY = 200;
426
+ function saveScroll(sid) {
427
+ if (!sid) return;
428
+ try { localStorage.setItem('scroll:' + sid, String(msgsEl.scrollTop)); } catch {}
429
+ }
430
+ function loadScroll(sid) {
431
+ try { const v = localStorage.getItem('scroll:' + sid); return v !== null ? parseInt(v, 10) : null; } catch { return null; }
432
+ }
433
+ function deleteScroll(sid) { localStorage.removeItem('scroll:' + sid); }
434
+
435
+ function appendHistory(sid, text, cls, msgId) {
436
+ try {
437
+ const key = 'hist:' + sid;
438
+ const hist = JSON.parse(localStorage.getItem(key) || '[]');
439
+ hist.push({ text, cls, id: msgId || null });
440
+ if (hist.length > MAX_HISTORY) hist.splice(0, hist.length - MAX_HISTORY);
441
+ localStorage.setItem(key, JSON.stringify(hist));
442
+ } catch {}
443
+ }
444
+ function loadHistory(sid) {
445
+ try { return JSON.parse(localStorage.getItem('hist:' + sid) || '[]'); } catch { return []; }
446
+ }
447
+ function deleteHistory(sid) { localStorage.removeItem('hist:' + sid); }
448
+
449
+ // ── DOM refs ──────────────────────────────────────────────────────────
450
+ const msgsEl = document.getElementById('messages');
451
+ const inputEl = document.getElementById('input');
452
+ const sendBtn = document.getElementById('send');
453
+ const sidebar = document.getElementById('sidebar');
454
+ const listEl = document.getElementById('session-list');
455
+ const headerTitle = document.getElementById('header-title');
456
+ const connDot = document.getElementById('conn-dot');
457
+ const headerCwd = document.getElementById('header-cwd');
458
+ const serverAddress = '__SERVER_ADDRESS__';
459
+
460
+ let typingEl = null;
461
+ let statusEl = null; // live tool-use status element (shown below typing indicator)
462
+ let sessionId = sessionStorage.getItem('sid');
463
+ let es = null; // EventSource
464
+ let sseGeneration = 0; // incremented each setupSSE() call; guards against stale events
465
+ let currentTyping = false; // last-known typing state from SSE
466
+ let currentStatus = null; // last-known status payload from SSE
467
+ let dragSrcId = null; // session being dragged
468
+ let dragOverEl = null; // item currently highlighted as drop target
469
+ let botHasResponded = false; // true after bot sends a reply; reset on new user message / session switch
470
+
471
+ // ── Unread tracking ───────────────────────────────────────────────────
472
+ // Persisted in localStorage ('seen:{sid}') so unread state survives reloads.
473
+ // A session is unread if server's lastMessage timestamp > last-seen timestamp.
474
+ const unreadSessions = new Set();
475
+ function loadLastSeen(sid) {
476
+ try { const v = localStorage.getItem('seen:' + sid); return v !== null ? parseInt(v, 10) : null; } catch { return null; }
477
+ }
478
+ function saveLastSeen(sid, ts) {
479
+ try { localStorage.setItem('seen:' + sid, String(ts)); } catch {}
480
+ }
481
+ function markRead(sid) {
482
+ saveLastSeen(sid, Date.now());
483
+ if (unreadSessions.delete(sid)) renderSessions();
484
+ }
485
+
486
+ // ── Connection indicator ──────────────────────────────────────────────
487
+ const connLabels = { connecting: 'Verbindet…', connected: 'Verbunden', disconnected: 'Getrennt' };
488
+ function setConnState(state) {
489
+ connDot.className = state;
490
+ connDot.title = connLabels[state] || '';
491
+ }
492
+ setConnState('connecting');
493
+
494
+ // ── Sidebar toggle ────────────────────────────────────────────────────
495
+ const isMobile = window.innerWidth <= 640;
496
+ // On mobile: always open initially (overlay mode); on desktop: respect saved pref
497
+ let sidebarOpen = isMobile ? true : (localStorage.getItem('sidebarOpen') !== 'false');
498
+ const backdrop = document.getElementById('sidebar-backdrop');
499
+ function setSidebar(open) {
500
+ sidebarOpen = open;
501
+ // Only persist state on desktop — on mobile sidebar is always an overlay
502
+ if (!isMobile) localStorage.setItem('sidebarOpen', String(open));
503
+ sidebar.classList.toggle('collapsed', !open);
504
+ // Backdrop only active (visible + interactive) when sidebar is open on mobile
505
+ if (backdrop) backdrop.style.display = (isMobile && open) ? 'block' : 'none';
506
+ }
507
+ setSidebar(sidebarOpen);
508
+ document.getElementById('toggle-sidebar').addEventListener('click', () => setSidebar(!sidebarOpen));
509
+ // Tap backdrop to close sidebar on mobile
510
+ if (backdrop) backdrop.addEventListener('click', () => setSidebar(false));
511
+
512
+ // ── Header ────────────────────────────────────────────────────────────
513
+ function updateHeader(cwd) {
514
+ headerTitle.textContent = 'NanoClaw \u2014 ' + serverAddress;
515
+ headerCwd.textContent = ' \u2014 ' + (cwd?.startsWith('/') ? cwd : '/' + (cwd || ''));
516
+ }
517
+
518
+ // ── Messages ──────────────────────────────────────────────────────────
519
+ const ICON_COPY = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>';
520
+ const ICON_CHECK = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>';
521
+ const ICON_TRASH = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg>';
522
+
523
+ function flashCopied(btn) {
524
+ btn.innerHTML = ICON_CHECK;
525
+ btn.classList.add('copied');
526
+ setTimeout(() => { btn.innerHTML = ICON_COPY; btn.classList.remove('copied'); }, 1500);
527
+ }
528
+
529
+ function renderMsg(text, cls, msgId) {
530
+ const row = document.createElement('div');
531
+ row.className = 'msg-row ' + (cls.startsWith('bot') || cls === 'status' ? 'bot' : 'user');
532
+ const d = document.createElement('div');
533
+ d.className = 'msg ' + cls;
534
+ if (cls === 'bot') {
535
+ d.innerHTML = marked.parse(text);
536
+ renderMathInElement(d, {
537
+ delimiters: [
538
+ { left: '$$', right: '$$', display: true },
539
+ { left: '$', right: '$', display: false },
540
+ { left: '\\\\[', right: '\\\\]', display: true },
541
+ { left: '\\\\(', right: '\\\\)', display: false }
542
+ ],
543
+ throwOnError: false
544
+ });
545
+ try { mermaid.run({ nodes: d.querySelectorAll('.mermaid') }); } catch { /* mermaid bug in strict mode — ignore */ }
546
+ // Add copy button to each fenced code block
547
+ d.querySelectorAll('pre').forEach(pre => {
548
+ const btn = document.createElement('button');
549
+ btn.className = 'copy-btn';
550
+ btn.innerHTML = ICON_COPY;
551
+ btn.title = 'Code kopieren';
552
+ btn.addEventListener('click', e => {
553
+ e.stopPropagation();
554
+ const code = pre.querySelector('code');
555
+ navigator.clipboard.writeText(code ? code.innerText : pre.innerText).then(() => flashCopied(btn)).catch(() => {});
556
+ });
557
+ pre.appendChild(btn);
558
+ });
559
+ // Add copy-whole-message button (top-right, same line as trash)
560
+ const copyBtn = document.createElement('button');
561
+ copyBtn.className = 'copy-btn';
562
+ copyBtn.innerHTML = ICON_COPY;
563
+ copyBtn.title = 'Antwort kopieren';
564
+ copyBtn.addEventListener('click', () => {
565
+ navigator.clipboard.writeText(text).then(() => flashCopied(copyBtn)).catch(() => {});
566
+ });
567
+ d.appendChild(copyBtn);
568
+ } else {
569
+ d.textContent = text;
570
+ }
571
+ // Trash button — inside the bubble, top corner, always visible; only for real messages with a DB id
572
+ row.appendChild(d);
573
+ // Trash button — outside the bubble, always visible; only for real messages with a DB id
574
+ if (msgId && cls !== 'bot typing' && cls !== 'status') {
575
+ const delBtn = document.createElement('button');
576
+ delBtn.className = 'del-btn';
577
+ delBtn.title = 'Nachricht löschen';
578
+ delBtn.innerHTML = ICON_TRASH;
579
+ delBtn.addEventListener('click', async () => {
580
+ try {
581
+ await fetch('/delete-message', {
582
+ method: 'POST',
583
+ headers: { 'Content-Type': 'application/json' },
584
+ body: JSON.stringify({ sid: sessionId, id: msgId }),
585
+ });
586
+ row.remove();
587
+ // Remove from localStorage cache
588
+ try {
589
+ const hist = loadHistory(sessionId).filter(m => m.id !== msgId);
590
+ localStorage.setItem('hist:' + sessionId, JSON.stringify(hist));
591
+ } catch {}
592
+ } catch {}
593
+ });
594
+ row.appendChild(delBtn);
595
+ }
596
+ msgsEl.appendChild(row);
597
+ msgsEl.scrollTop = msgsEl.scrollHeight;
598
+ return row;
599
+ }
600
+
601
+ function addMsg(text, cls, msgId) {
602
+ // Save to history (skip typing indicator)
603
+ if (cls !== 'bot typing') appendHistory(sessionId, text, cls, msgId);
604
+ return renderMsg(text, cls, msgId);
605
+ }
606
+
607
+ const cancelBtn = document.getElementById('cancel-btn');
608
+
609
+ function setTyping(on) {
610
+ currentTyping = on;
611
+ if (on && !typingEl) { typingEl = renderMsg('…', 'bot typing'); }
612
+ if (!on && typingEl) { typingEl.remove(); typingEl = null; }
613
+ if (!on) setStatusDisplay(null); // clear status when typing stops
614
+ cancelBtn.style.display = on ? 'block' : 'none';
615
+ }
616
+
617
+ cancelBtn.addEventListener('click', () => {
618
+ cancelBtn.disabled = true;
619
+ fetch('/cancel?sid=' + sessionId, { method: 'POST' })
620
+ .catch(() => {})
621
+ .finally(() => { cancelBtn.disabled = false; });
622
+ });
623
+
624
+ /** Show/update/clear the live tool-use status line below the typing indicator. */
625
+ function setStatusDisplay(tool, inputSnippet) {
626
+ currentStatus = tool ? { tool, inputSnippet } : null;
627
+ if (!tool) {
628
+ if (statusEl) { statusEl.remove(); statusEl = null; }
629
+ return;
630
+ }
631
+ // Format display text: tool name + optional brief input snippet
632
+ let label = tool === 'thinking' ? 'thinking\u2026' : tool + '\u2026';
633
+ if (inputSnippet && tool !== 'thinking') {
634
+ const snippet = inputSnippet.length > 60 ? inputSnippet.slice(0, 60) + '\u2026' : inputSnippet;
635
+ label = tool + ': ' + snippet;
636
+ }
637
+ if (!statusEl) {
638
+ statusEl = document.createElement('div');
639
+ statusEl.className = 'msg status';
640
+ msgsEl.appendChild(statusEl);
641
+ }
642
+ statusEl.textContent = label;
643
+ msgsEl.scrollTop = msgsEl.scrollHeight;
644
+ }
645
+
646
+ function restoreHistory(sid) {
647
+ for (const { text, cls, id } of loadHistory(sid)) renderMsg(text, cls, id);
648
+ const saved = loadScroll(sid);
649
+ msgsEl.scrollTop = saved !== null ? saved : msgsEl.scrollHeight;
650
+ }
651
+
652
+ async function fetchServerHistory(sid) {
653
+ try {
654
+ const resp = await fetch('/history?sid=' + sid);
655
+ if (!resp.ok) return;
656
+ if (sid !== sessionId) return; // session changed while fetching — discard stale result
657
+ const hist = await resp.json();
658
+ if (sid !== sessionId) return; // check again after JSON parse
659
+ msgsEl.innerHTML = '';
660
+ typingEl = null;
661
+ statusEl = null;
662
+ for (const { text, cls, id } of hist) renderMsg(text, cls, id);
663
+ // Re-append typing/status indicators only if the agent is still processing.
664
+ // If the last DB message is already a bot response, the agent has finished —
665
+ // force-clear any stale typing/status state (server's sessionTyping may lag
666
+ // behind due to a race between the bot response and an SSE reconnect).
667
+ const agentDone = hist.length > 0 && hist[hist.length - 1].cls === 'bot';
668
+ if (agentDone) {
669
+ botHasResponded = true;
670
+ setTyping(false); // resets currentTyping + currentStatus via setStatusDisplay(null)
671
+ } else {
672
+ if (currentTyping) setTyping(true);
673
+ if (currentStatus) setStatusDisplay(currentStatus.tool, currentStatus.inputSnippet);
674
+ }
675
+ const saved = loadScroll(sid);
676
+ msgsEl.scrollTop = saved !== null ? saved : msgsEl.scrollHeight;
677
+ // Mark this session as read — history was fully loaded, user has "seen" all messages
678
+ markRead(sid);
679
+ // Cache in localStorage for faster access next time
680
+ try {
681
+ localStorage.setItem('hist:' + sid, JSON.stringify(
682
+ hist.slice(-MAX_HISTORY)
683
+ ));
684
+ } catch {}
685
+ } catch {}
686
+ }
687
+
688
+ async function mergeServerSessions() {
689
+ try {
690
+ const resp = await fetch('/sessions');
691
+ if (!resp.ok) return;
692
+ const payload = await resp.json();
693
+ // Response is now { sessions, order } — fall back to plain array for compat
694
+ const serverSessions = Array.isArray(payload) ? payload : (payload.sessions ?? []);
695
+ const serverOrder = Array.isArray(payload) ? [] : (payload.order ?? []);
696
+ const serverIds = new Set(serverSessions.map(ss => ss.id));
697
+ let local = loadSessions();
698
+ let changed = false;
699
+
700
+ // Add or update sessions from server
701
+ for (const ss of serverSessions) {
702
+ const existing = local.find(s => s.id === ss.id);
703
+ if (!existing) {
704
+ // New session from another window/browser — use server name, fall back to date label
705
+ const serverName = ss.name && ss.name !== 'Web Chat' ? ss.name : null;
706
+ const lastMsgDate = ss.lastMessage ? new Date(ss.lastMessage) : null;
707
+ const validDate = (lastMsgDate && !isNaN(lastMsgDate.getTime())) ? lastMsgDate : new Date();
708
+ // unshift() so new sessions appear at the top without re-sorting the whole
709
+ // list (which would destroy any manual drag-and-drop ordering).
710
+ local.unshift({
711
+ id: ss.id,
712
+ name: serverName || sessionLabel(validDate),
713
+ createdAt: validDate.getTime(),
714
+ fromServer: true,
715
+ nameUpdatedAt: ss.nameUpdatedAt || 0,
716
+ });
717
+ changed = true;
718
+ } else {
719
+ // Mark as known to server
720
+ if (!existing.fromServer) { existing.fromServer = true; changed = true; }
721
+ // Compare nameUpdatedAt timestamps to decide who wins.
722
+ // Default to createdAt (or 0) if nameUpdatedAt is missing (old data).
723
+ const localTs = existing.nameUpdatedAt ?? existing.createdAt ?? 0;
724
+ const serverTs = ss.nameUpdatedAt || 0;
725
+ if (serverTs > localTs && ss.name && ss.name !== 'Web Chat') {
726
+ // Server has a newer rename → update local
727
+ existing.name = ss.name;
728
+ existing.nameUpdatedAt = serverTs;
729
+ changed = true;
730
+ } else if (localTs > serverTs && existing.name && existing.name !== 'Web Chat' && existing.name !== ss.name) {
731
+ // Local is newer → push back to server so other devices sync
732
+ // (server-side guard prevents overwrite if server already has something newer)
733
+ pushNameToServer(existing.id, existing.name, localTs);
734
+ }
735
+ }
736
+ }
737
+
738
+ // Remove local sessions that were from the server but are no longer there (deleted elsewhere)
739
+ const before = local.length;
740
+ let activeSessionRemoved = false;
741
+ local = local.filter(s => {
742
+ if (!s.fromServer) return true; // locally-created, not yet confirmed by server → keep
743
+ if (s.isOwn) return true; // created in this browser → never auto-remove
744
+ if (serverIds.has(s.id)) return true; // still on server → keep
745
+ if (s.id === sessionId) activeSessionRemoved = true;
746
+ deleteHistory(s.id);
747
+ localStorage.removeItem('cwd:' + s.id);
748
+ unreadSessions.delete(s.id);
749
+ return false; // remove
750
+ });
751
+ if (local.length !== before) {
752
+ changed = true;
753
+ if (activeSessionRemoved) {
754
+ // Switch to first remaining session after filter is fully applied
755
+ if (local.length > 0) setTimeout(() => switchSession(local[0].id), 0);
756
+ else setTimeout(newSession, 0);
757
+ }
758
+ }
759
+
760
+ // Sync CWD from server for all sessions
761
+ for (const ss of serverSessions) {
762
+ if (ss.cwd) {
763
+ const localCwd = loadCwd(ss.id);
764
+ if (ss.cwd !== localCwd) {
765
+ saveCwd(ss.id, ss.cwd);
766
+ if (ss.id === sessionId) {
767
+ sessionStorage.setItem('currentCwd', ss.cwd);
768
+ updateHeader(ss.cwd);
769
+ }
770
+ }
771
+ }
772
+ }
773
+
774
+ // Update unread indicators: a session is unread if the server's lastMessage
775
+ // is newer than the stored last-seen timestamp for that session.
776
+ for (const ss of serverSessions) {
777
+ if (ss.id === sessionId) { saveLastSeen(ss.id, Date.now()); continue; } // current session → always seen
778
+ if (!ss.lastMessage) continue;
779
+ const msgTs = new Date(ss.lastMessage).getTime();
780
+ const seen = loadLastSeen(ss.id);
781
+ if (seen === null) { saveLastSeen(ss.id, msgTs); continue; } // first time ever: init without marking unread
782
+ if (msgTs > seen && !unreadSessions.has(ss.id)) { unreadSessions.add(ss.id); changed = true; }
783
+ }
784
+
785
+ // Apply server-defined order if available (from /session-order endpoint).
786
+ // This ensures cross-device/cross-browser order sync.
787
+ if (serverOrder.length > 0) {
788
+ const orderMap = new Map(serverOrder.map((id, i) => [id, i]));
789
+ const maxOrder = serverOrder.length;
790
+ const prevOrder = local.map(s => s.id).join(',');
791
+ local.sort((a, b) => {
792
+ // isOwn sessions not yet confirmed in serverOrder stay above all server-ordered sessions
793
+ const rawIa = orderMap.get(a.id);
794
+ const rawIb = orderMap.get(b.id);
795
+ const ia = rawIa !== undefined ? rawIa : (a.isOwn ? -1 : maxOrder);
796
+ const ib = rawIb !== undefined ? rawIb : (b.isOwn ? -1 : maxOrder);
797
+ if (ia !== ib) return ia - ib;
798
+ // Sessions not in the order list: sort by newest first
799
+ return (b.createdAt || 0) - (a.createdAt || 0);
800
+ });
801
+ const newOrder = local.map(s => s.id).join(',');
802
+ if (newOrder !== prevOrder) changed = true;
803
+ }
804
+
805
+ if (changed) {
806
+ saveSessions(local);
807
+ if (!isRenaming()) renderSessions();
808
+ }
809
+ } catch {}
810
+ }
811
+
812
+ msgsEl.addEventListener('click', e => {
813
+ const a = e.target.closest('a');
814
+ if (!a) return;
815
+ const href = a.getAttribute('href') || '';
816
+ if (href.startsWith('topic:')) {
817
+ e.preventDefault();
818
+ inputEl.value = 'switching to folder: ' + href.slice('topic:'.length);
819
+ sendMsg();
820
+ }
821
+ });
822
+
823
+ // ── SSE ───────────────────────────────────────────────────────────────
824
+ function setupSSE() {
825
+ if (es) es.close();
826
+ setConnState('connecting');
827
+ const myGen = ++sseGeneration; // guard: ignore events from superseded connections
828
+ const mySid = sessionId;
829
+ es = new EventSource('/events?sid=' + sessionId);
830
+ // On SSE open: push current session name to server so ensureSession() (which
831
+ // runs server-side on first connect and may default to "Web Chat") sees the
832
+ // correct name right away. This fixes the race on Android/mobile where the
833
+ // pushNameToServer from newSession() might arrive AFTER ensureSession() runs.
834
+ let sseEverConnected = false; // distinguish initial connect from reconnects
835
+ es.addEventListener('open', () => {
836
+ if (sseGeneration !== myGen || sessionId !== mySid) return;
837
+ const wasConnected = sseEverConnected;
838
+ sseEverConnected = true;
839
+ setConnState('connected');
840
+ // Push session name on every connect so ensureSession() on the server (which
841
+ // runs after the SSE handshake and may default to "Web Chat") always sees the
842
+ // correct name. The server-side guard (name_updated_at comparison) ensures
843
+ // a stale local push never overwrites a newer server-side rename.
844
+ const _s = loadSessions().find(s => s.id === sessionId);
845
+ if (_s?.name) pushNameToServer(sessionId, _s.name, _s.nameUpdatedAt ?? _s.createdAt ?? Date.now());
846
+ // On reconnect (not initial connect): re-fetch history to catch missed messages
847
+ // (e.g. bot response or typing: false delivered while the connection was down).
848
+ // Always fetch the current CWD from the server on (re)connect so the header
849
+ // reflects the server's ground truth — not just a stale localStorage value.
850
+ const sidAtOpen = sessionId;
851
+ fetch('/pwd?sid=' + sessionId).then(r => r.json()).then(data => {
852
+ if (sessionId !== sidAtOpen || sseGeneration !== myGen) return; // stale
853
+ if (data.cwd) {
854
+ saveCwd(sessionId, data.cwd);
855
+ sessionStorage.setItem('currentCwd', data.cwd);
856
+ updateHeader(data.cwd);
857
+ }
858
+ }).catch(() => {});
859
+ if (wasConnected) {
860
+ // Reset stale typing/status before re-fetching history. The SSE's
861
+ // initial typing/status events will restore the correct state.
862
+ setTyping(false);
863
+ fetchServerHistory(sessionId);
864
+ }
865
+ });
866
+ es.addEventListener('error', () => { if (sseGeneration === myGen) setConnState('disconnected'); });
867
+ es.addEventListener('message', e => {
868
+ if (sseGeneration !== myGen || sessionId !== mySid) return; // stale — discard
869
+ setStatusDisplay(null); // clear live status when response arrives
870
+ setTyping(false);
871
+ botHasResponded = true;
872
+ try {
873
+ const payload = JSON.parse(e.data);
874
+ // payload is either {text, id} (new format) or a plain string (backward compat)
875
+ const text = typeof payload === 'string' ? payload : payload.text;
876
+ const msgId = typeof payload === 'object' && payload ? payload.id : null;
877
+ addMsg(text, 'bot', msgId);
878
+ } catch { /* render error — don't block markRead */ }
879
+ markRead(sessionId); // message arrived in active session → keep it read
880
+ });
881
+ es.addEventListener('typing', e => {
882
+ if (sseGeneration !== myGen || sessionId !== mySid) return;
883
+ // Ignore stale "typing: true" from the server's initial SSE state push
884
+ // if the bot has already sent its response (race: SSE events arrive after
885
+ // fetchServerHistory has already confirmed agentDone).
886
+ if (e.data === 'true' && botHasResponded) return;
887
+ setTyping(e.data === 'true');
888
+ });
889
+ es.addEventListener('status', e => {
890
+ if (sseGeneration !== myGen || sessionId !== mySid) return;
891
+ try {
892
+ const payload = JSON.parse(e.data);
893
+ if (!payload) { setStatusDisplay(null); return; }
894
+ // Ignore stale status (e.g. "thinking") if bot has already responded
895
+ if (botHasResponded) return;
896
+ setStatusDisplay(payload.tool || null, payload.input || null);
897
+ } catch { /* ignore malformed status event */ }
898
+ });
899
+ es.addEventListener('cwd', e => {
900
+ if (sseGeneration !== myGen || sessionId !== mySid) return;
901
+ try {
902
+ const cwd = JSON.parse(e.data);
903
+ saveCwd(sessionId, cwd);
904
+ sessionStorage.setItem('currentCwd', cwd);
905
+ updateHeader(cwd);
906
+ } catch { /* ignore malformed cwd event */ }
907
+ });
908
+ }
909
+
910
+ // ── Session management ────────────────────────────────────────────────
911
+ function setInputEnabled(enabled) {
912
+ inputEl.disabled = !enabled;
913
+ sendBtn.disabled = !enabled;
914
+ inputEl.placeholder = enabled ? 'Type a message\u2026' : 'Dieses Tab ist für geplante Aufgaben reserviert.';
915
+ }
916
+
917
+ function switchSession(newId) {
918
+ saveScroll(sessionId); // persist scroll position of outgoing session
919
+ sessionId = newId; // set before markRead so renderSessions sees correct active session
920
+ markRead(newId); // clear unread indicator (uses updated sessionId)
921
+ sessionStorage.setItem('sid', newId);
922
+ msgsEl.innerHTML = '';
923
+ typingEl = null; // reset before setTyping so it doesn't try to .remove() a stale element
924
+ statusEl = null; // reset before setStatusDisplay for the same reason
925
+ botHasResponded = false; // reset: we don't yet know if the new session's bot has responded
926
+ setTyping(false); // resets currentTyping, hides cancel button, clears status via setStatusDisplay(null)
927
+ setInputEnabled(newId !== 'cron');
928
+ // Always fetch complete history from the server so that user messages
929
+ // written in other browser tabs/windows are not missing. (If only bot
930
+ // responses were cached locally via SSE, restoreHistory would skip user
931
+ // messages entirely.)
932
+ restoreHistory(newId); // show local cache immediately while fetching
933
+ fetchServerHistory(newId); // always sync complete history from DB
934
+ const cwd = loadCwd(newId);
935
+ sessionStorage.setItem('currentCwd', cwd);
936
+ updateHeader(cwd);
937
+ setupSSE();
938
+ renderSessions();
939
+ if (newId !== 'cron') inputEl.focus();
940
+ }
941
+
942
+ function pushNameToServer(sid, name, nameUpdatedAt) {
943
+ fetch('/session-name', {
944
+ method: 'POST',
945
+ headers: { 'Content-Type': 'application/json' },
946
+ body: JSON.stringify({ sid, name, nameUpdatedAt: nameUpdatedAt ?? Date.now() }),
947
+ }).catch(() => {});
948
+ }
949
+
950
+ function syncOrderToServer(list) {
951
+ fetch('/session-order', {
952
+ method: 'POST',
953
+ headers: { 'Content-Type': 'application/json' },
954
+ body: JSON.stringify({ order: list.map(x => x.id) }),
955
+ }).catch(() => {});
956
+ }
957
+
958
+ function newSession() {
959
+ const id = uuid();
960
+ const name = sessionLabel(new Date());
961
+ const nameUpdatedAt = Date.now();
962
+ const list = loadSessions();
963
+ list.unshift({ id, name, createdAt: nameUpdatedAt, isOwn: true, nameUpdatedAt });
964
+ saveSessions(list);
965
+ pushNameToServer(id, name, nameUpdatedAt);
966
+ syncOrderToServer(list);
967
+ switchSession(id);
968
+ }
969
+
970
+ function deleteSession(id) {
971
+ let list = loadSessions().filter(s => s.id !== id);
972
+ localStorage.removeItem('cwd:' + id);
973
+ deleteHistory(id);
974
+ deleteScroll(id);
975
+ unreadSessions.delete(id);
976
+ // Notify server so other browsers can sync the deletion
977
+ fetch('/delete-session', {
978
+ method: 'POST',
979
+ headers: { 'Content-Type': 'application/json' },
980
+ body: JSON.stringify({ sid: id }),
981
+ }).catch(() => {});
982
+ if (list.length === 0) {
983
+ // Always keep at least one session
984
+ const newId = uuid();
985
+ const newName = sessionLabel(new Date());
986
+ const newNameUpdatedAt = Date.now();
987
+ list = [{ id: newId, name: newName, createdAt: newNameUpdatedAt, isOwn: true, nameUpdatedAt: newNameUpdatedAt }];
988
+ saveSessions(list);
989
+ pushNameToServer(newId, newName, newNameUpdatedAt);
990
+ switchSession(newId);
991
+ } else {
992
+ saveSessions(list);
993
+ if (id === sessionId) switchSession(list[0].id);
994
+ else renderSessions();
995
+ }
996
+ }
997
+
998
+ function renameSession(id, newName) {
999
+ const list = loadSessions();
1000
+ const s = list.find(s => s.id === id);
1001
+ if (s && newName.trim()) {
1002
+ s.name = newName.trim();
1003
+ s.nameUpdatedAt = Date.now();
1004
+ saveSessions(list);
1005
+ pushNameToServer(id, s.name, s.nameUpdatedAt);
1006
+ }
1007
+ renderSessions();
1008
+ }
1009
+
1010
+ function startRename(id, nameEl) {
1011
+ const current = nameEl.textContent;
1012
+ const inp = document.createElement('input');
1013
+ inp.className = 'session-name-input';
1014
+ inp.value = current;
1015
+ nameEl.replaceWith(inp);
1016
+ inp.focus();
1017
+ inp.select();
1018
+ const commit = () => { renameSession(id, inp.value || current); };
1019
+ inp.addEventListener('blur', commit);
1020
+ inp.addEventListener('keydown', e => {
1021
+ if (e.key === 'Enter') { e.preventDefault(); inp.blur(); }
1022
+ if (e.key === 'Escape') { inp.value = current; inp.blur(); }
1023
+ });
1024
+ }
1025
+
1026
+ /** Returns true while a session rename input is active — suppresses background re-renders. */
1027
+ function isRenaming() {
1028
+ return !!listEl.querySelector('.session-name-input');
1029
+ }
1030
+
1031
+ function renderSessions() {
1032
+ const list = loadSessions();
1033
+ listEl.innerHTML = '';
1034
+ for (const s of list) {
1035
+ const item = document.createElement('div');
1036
+ const isUnread = unreadSessions.has(s.id) && s.id !== sessionId;
1037
+ item.className = 'session-item' + (s.id === sessionId ? ' active' : '') + (isUnread ? ' unread' : '');
1038
+ item.dataset.id = s.id;
1039
+ item.draggable = true;
1040
+
1041
+ const dot = document.createElement('span');
1042
+ dot.className = 'session-unread-dot';
1043
+
1044
+ const nameSpan = document.createElement('span');
1045
+ nameSpan.className = 'session-name';
1046
+ const displayName = s.name || sessionLabel(new Date(s.createdAt || Date.now()));
1047
+ nameSpan.textContent = displayName;
1048
+ nameSpan.title = displayName;
1049
+
1050
+ const actions = document.createElement('div');
1051
+ actions.className = 'session-actions';
1052
+
1053
+ const renameBtn = document.createElement('button');
1054
+ renameBtn.className = 'session-btn';
1055
+ renameBtn.title = 'Umbenennen';
1056
+ renameBtn.textContent = '✏';
1057
+ renameBtn.addEventListener('click', e => { e.stopPropagation(); startRename(s.id, nameSpan); });
1058
+
1059
+ const delBtn = document.createElement('button');
1060
+ delBtn.className = 'session-btn';
1061
+ delBtn.title = 'Löschen';
1062
+ delBtn.textContent = '×';
1063
+ delBtn.addEventListener('click', e => { e.stopPropagation(); deleteSession(s.id); });
1064
+
1065
+ actions.append(renameBtn, delBtn);
1066
+ item.append(dot, nameSpan, actions);
1067
+ item.addEventListener('click', () => {
1068
+ if (s.id !== sessionId) switchSession(s.id);
1069
+ if (isMobile) setSidebar(false); // close overlay after selection on mobile
1070
+ });
1071
+
1072
+ // ── Drag-and-drop reordering ────────────────────────────────────
1073
+ item.addEventListener('dragstart', e => {
1074
+ dragSrcId = s.id;
1075
+ e.dataTransfer.effectAllowed = 'move';
1076
+ item.classList.add('drag-src');
1077
+ });
1078
+ item.addEventListener('dragend', () => {
1079
+ dragSrcId = null;
1080
+ item.classList.remove('drag-src');
1081
+ if (dragOverEl) { dragOverEl.classList.remove('drag-over'); dragOverEl = null; }
1082
+ });
1083
+ item.addEventListener('dragover', e => {
1084
+ if (!dragSrcId || dragSrcId === s.id) return;
1085
+ e.preventDefault();
1086
+ e.dataTransfer.dropEffect = 'move';
1087
+ if (dragOverEl && dragOverEl !== item) { dragOverEl.classList.remove('drag-over'); }
1088
+ dragOverEl = item;
1089
+ item.classList.add('drag-over');
1090
+ });
1091
+ item.addEventListener('dragleave', e => {
1092
+ // Only remove if leaving the item entirely (not entering a child)
1093
+ if (!item.contains(e.relatedTarget)) {
1094
+ item.classList.remove('drag-over');
1095
+ if (dragOverEl === item) dragOverEl = null;
1096
+ }
1097
+ });
1098
+ item.addEventListener('drop', e => {
1099
+ e.preventDefault();
1100
+ item.classList.remove('drag-over');
1101
+ dragOverEl = null;
1102
+ if (!dragSrcId || dragSrcId === s.id) return;
1103
+ const sessions = loadSessions();
1104
+ const fromIdx = sessions.findIndex(x => x.id === dragSrcId);
1105
+ const toIdx = sessions.findIndex(x => x.id === s.id);
1106
+ if (fromIdx < 0 || toIdx < 0) return;
1107
+ const [moved] = sessions.splice(fromIdx, 1);
1108
+ sessions.splice(toIdx, 0, moved);
1109
+ saveSessions(sessions);
1110
+ renderSessions();
1111
+ syncOrderToServer(sessions);
1112
+ });
1113
+
1114
+ listEl.appendChild(item);
1115
+ }
1116
+ }
1117
+
1118
+ // ── Init ──────────────────────────────────────────────────────────────
1119
+ if (!sessionId) {
1120
+ sessionId = uuid();
1121
+ sessionStorage.setItem('sid', sessionId);
1122
+ }
1123
+ const isNewSession = ensureSessionInList(sessionId);
1124
+ // For new sessions: push name to server immediately.
1125
+ // For existing sessions: SSE open event handles it (with nameUpdatedAt so server
1126
+ // guard ensures only a genuinely newer name overwrites a server-side rename).
1127
+ if (isNewSession) {
1128
+ const _s = loadSessions().find(s => s.id === sessionId);
1129
+ if (_s?.name) pushNameToServer(sessionId, _s.name, _s.nameUpdatedAt ?? _s.createdAt ?? Date.now());
1130
+ }
1131
+ markRead(sessionId); // ensure current session never appears as unread on first poll
1132
+ setInputEnabled(sessionId !== 'cron');
1133
+ renderSessions();
1134
+ restoreHistory(sessionId); // show local cache immediately (fast)
1135
+ fetchServerHistory(sessionId); // sync full history from DB (catches cross-device messages)
1136
+ const initCwd = loadCwd(sessionId) || sessionStorage.getItem('currentCwd') || '';
1137
+ updateHeader(initCwd);
1138
+ setupSSE();
1139
+
1140
+ document.getElementById('new-session-btn').addEventListener('click', newSession);
1141
+ mergeServerSessions(); // populate sidebar with sessions known to the server
1142
+
1143
+ // ── Cross-window sync ─────────────────────────────────────────────────
1144
+ let lastSessionsJson = localStorage.getItem('sessions');
1145
+ function checkAndSyncSessions() {
1146
+ const current = localStorage.getItem('sessions');
1147
+ if (current !== lastSessionsJson) {
1148
+ lastSessionsJson = current;
1149
+ if (!isRenaming()) renderSessions();
1150
+ }
1151
+ }
1152
+ // Primary: storage event (fires immediately when another tab in the same browser changes localStorage)
1153
+ window.addEventListener('storage', e => {
1154
+ if (e.key === 'sessions' || e.key === null) {
1155
+ lastSessionsJson = localStorage.getItem('sessions');
1156
+ if (!isRenaming()) renderSessions();
1157
+ }
1158
+ });
1159
+ // Fast polling for same-browser cross-tab sync (fallback)
1160
+ setInterval(checkAndSyncSessions, 1000);
1161
+ // Server polling: picks up sessions from other browsers/devices
1162
+ setInterval(mergeServerSessions, 5000);
1163
+
1164
+ // ── Send message ──────────────────────────────────────────────────────
1165
+ async function sendMsg() {
1166
+ const text = inputEl.value.trim();
1167
+ if (!text) return;
1168
+ inputEl.value = '';
1169
+ inputEl.style.height = '';
1170
+
1171
+ // ── Built-in commands (not forwarded to bot) ──────────────────────────
1172
+ if (text === '/clear') {
1173
+ msgsEl.innerHTML = '';
1174
+ typingEl = null;
1175
+ deleteHistory(sessionId);
1176
+ fetch('/clear-history', {
1177
+ method: 'POST',
1178
+ headers: { 'Content-Type': 'application/json' },
1179
+ body: JSON.stringify({ sid: sessionId }),
1180
+ }).catch(() => {});
1181
+ inputEl.focus();
1182
+ return;
1183
+ }
1184
+
1185
+ botHasResponded = false; // reset: new user message, bot hasn't responded to this one yet
1186
+ // Generate ID client-side so we can assign it to the DOM element immediately
1187
+ const userMsgId = 'web-' + Date.now() + '-' + Math.random().toString(36).slice(2, 8);
1188
+ addMsg(text, 'user', userMsgId);
1189
+ sendBtn.disabled = true;
1190
+ try {
1191
+ const cwdMatch = text.match(/^switching to folder:\\s*(.+)$/i);
1192
+ if (cwdMatch) {
1193
+ const cwd = cwdMatch[1].trim();
1194
+ saveCwd(sessionId, cwd);
1195
+ sessionStorage.setItem('currentCwd', cwd);
1196
+ updateHeader(cwd);
1197
+ }
1198
+ await fetch('/message', {
1199
+ method: 'POST',
1200
+ headers: { 'Content-Type': 'application/json' },
1201
+ body: JSON.stringify({ content: text, sessionId, id: userMsgId }),
1202
+ });
1203
+ } finally {
1204
+ sendBtn.disabled = false;
1205
+ inputEl.focus();
1206
+ }
1207
+ }
1208
+
1209
+ sendBtn.addEventListener('click', sendMsg);
1210
+ inputEl.addEventListener('keydown', e => {
1211
+ if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMsg(); }
1212
+ });
1213
+ inputEl.addEventListener('input', () => {
1214
+ inputEl.style.height = 'auto';
1215
+ inputEl.style.height = Math.min(inputEl.scrollHeight, 120) + 'px';
1216
+ });
1217
+
1218
+ // ── File drop ─────────────────────────────────────────────────────────
1219
+ const overlay = document.getElementById('drop-overlay');
1220
+ function toBase64(buf) {
1221
+ let binary = '';
1222
+ const bytes = new Uint8Array(buf);
1223
+ for (let i = 0; i < bytes.length; i += 8192)
1224
+ binary += String.fromCharCode(...bytes.slice(i, i + 8192));
1225
+ return btoa(binary);
1226
+ }
1227
+ document.addEventListener('dragover', e => { e.preventDefault(); overlay.classList.add('active'); });
1228
+ document.addEventListener('dragleave', e => { if (!e.relatedTarget) overlay.classList.remove('active'); });
1229
+ document.addEventListener('drop', async e => {
1230
+ e.preventDefault();
1231
+ overlay.classList.remove('active');
1232
+ for (const file of [...e.dataTransfer.files]) {
1233
+ const b64 = toBase64(await file.arrayBuffer());
1234
+ const res = await fetch('/upload', {
1235
+ method: 'POST',
1236
+ headers: { 'Content-Type': 'application/json' },
1237
+ body: JSON.stringify({ filename: file.name, data: b64, sessionId })
1238
+ });
1239
+ const json = await res.json();
1240
+ addMsg(json.ok ? 'Gespeichert: ' + json.path : 'Fehler: ' + json.error, 'bot');
1241
+ }
1242
+ });
1243
+ </script>
1244
+ </body>`;
1245
+ class WebChannel {
1246
+ name = 'web';
1247
+ server = null;
1248
+ connected = false;
1249
+ onMessage;
1250
+ onChatMetadata;
1251
+ registerGroup;
1252
+ onCancelRequest;
1253
+ constructor(opts) {
1254
+ this.onMessage = opts.onMessage;
1255
+ this.onChatMetadata = opts.onChatMetadata;
1256
+ this.registerGroup = opts.registerGroup;
1257
+ this.onCancelRequest = opts.onCancelRequest;
1258
+ }
1259
+ ensureSession(sessionId) {
1260
+ if (registeredSessions.has(sessionId))
1261
+ return;
1262
+ registeredSessions.add(sessionId);
1263
+ const jid = WEB_JID_PREFIX + sessionId;
1264
+ // Preserve custom name if already set (e.g. by /session-name endpoint)
1265
+ const existing = getAllChats().find((c) => c.jid === jid);
1266
+ const chatName = existing?.name && existing.name !== GROUP_NAME
1267
+ ? existing.name
1268
+ : GROUP_NAME;
1269
+ if (this.registerGroup) {
1270
+ // Each web session gets its own IPC-isolated folder so that parallel
1271
+ // containers don't accidentally read each other's follow-up IPC messages.
1272
+ //
1273
+ // • Cron session → CRON_GROUP_FOLDER ('web-cron') — own workspace + IPC
1274
+ // • User sessions → 'web-{sessionId}' — unique IPC directory, but the
1275
+ // workspace (groups/main/) and Claude sessions dir (data/sessions/main/)
1276
+ // are shared via symlinks so all sessions see the same agent context,
1277
+ // memory, skills, tools, etc.
1278
+ let folder;
1279
+ if (sessionId === CRON_SESSION_ID) {
1280
+ folder = CRON_GROUP_FOLDER;
1281
+ }
1282
+ else {
1283
+ folder = 'web-' + sessionId;
1284
+ // Create symlinks so this session's containers share the main workspace
1285
+ // and .claude directory while getting an isolated IPC directory.
1286
+ const groupLink = path.join(GROUPS_DIR, folder);
1287
+ if (!fs.existsSync(groupLink)) {
1288
+ try {
1289
+ fs.symlinkSync('main', groupLink);
1290
+ }
1291
+ catch {
1292
+ /* already exists */
1293
+ }
1294
+ }
1295
+ const sessionsLink = path.join(DATA_DIR, 'sessions', folder);
1296
+ if (!fs.existsSync(sessionsLink)) {
1297
+ try {
1298
+ fs.symlinkSync('main', sessionsLink);
1299
+ }
1300
+ catch {
1301
+ /* already exists */
1302
+ }
1303
+ }
1304
+ }
1305
+ this.registerGroup(jid, {
1306
+ name: chatName,
1307
+ folder,
1308
+ trigger: '',
1309
+ added_at: new Date().toISOString(),
1310
+ requiresTrigger: false,
1311
+ isMain: true,
1312
+ });
1313
+ }
1314
+ // Use epoch timestamp so MAX() in storeChatMetadata never overwrites the actual
1315
+ // last_message_time — we only want to register name/channel/isGroup here.
1316
+ this.onChatMetadata(jid, '1970-01-01T00:00:00.000Z', chatName, 'web', false);
1317
+ }
1318
+ /**
1319
+ * Remove web-session symlinks in GROUPS_DIR and DATA_DIR/sessions that no
1320
+ * longer have a corresponding entry in the chats DB table.
1321
+ * Runs once at startup so orphans from deleted/expired sessions don't pile up.
1322
+ */
1323
+ cleanupOrphanedSessionLinks() {
1324
+ const known = new Set(getAllChats()
1325
+ .map((c) => c.jid)
1326
+ .filter((j) => j.startsWith(WEB_JID_PREFIX) &&
1327
+ j !== WEB_JID_PREFIX + CRON_SESSION_ID)
1328
+ .map((j) => j.slice(WEB_JID_PREFIX.length)));
1329
+ for (const dir of [GROUPS_DIR, path.join(DATA_DIR, 'sessions')]) {
1330
+ let entries;
1331
+ try {
1332
+ entries = fs.readdirSync(dir);
1333
+ }
1334
+ catch {
1335
+ continue;
1336
+ }
1337
+ for (const entry of entries) {
1338
+ if (!entry.startsWith('web-'))
1339
+ continue;
1340
+ const sid = entry.slice('web-'.length);
1341
+ if (!known.has(sid)) {
1342
+ try {
1343
+ fs.unlinkSync(path.join(dir, entry));
1344
+ logger.debug({ entry, dir }, 'Removed orphaned session symlink');
1345
+ }
1346
+ catch {
1347
+ /* ignore — may already be gone */
1348
+ }
1349
+ }
1350
+ }
1351
+ }
1352
+ }
1353
+ async connect() {
1354
+ this.server = http.createServer((req, res) => {
1355
+ // ── Token-based access control ──────────────────────────────────────────
1356
+ // authorizeRequest() returns false and sends 401 if the token is wrong.
1357
+ // If the token arrived via ?token= it also sets a session cookie so that
1358
+ // subsequent requests (SSE, API calls, asset loads) pass without repeating it.
1359
+ if (!authorizeRequest(req, res))
1360
+ return;
1361
+ if (req.method === 'GET' && req.url?.split('?')[0] === '/') {
1362
+ const html = HTML.replaceAll('__SERVER_ADDRESS__', `${getLocalIp()}:${PORT}`);
1363
+ res.writeHead(200, {
1364
+ 'Content-Type': 'text/html; charset=utf-8',
1365
+ 'Cache-Control': 'no-cache, no-store, must-revalidate',
1366
+ Pragma: 'no-cache',
1367
+ Expires: '0',
1368
+ });
1369
+ res.end(html);
1370
+ return;
1371
+ }
1372
+ // GET /sessions — list all web sessions known to the server (from DB)
1373
+ // Response: { sessions: Session[], order: string[] }
1374
+ // order contains session IDs in the user-defined drag order (empty = no custom order).
1375
+ if (req.method === 'GET' && req.url === '/sessions') {
1376
+ const chats = getAllChats();
1377
+ const sessions = chats
1378
+ .filter((c) => c.jid.startsWith(WEB_JID_PREFIX))
1379
+ .map((c) => {
1380
+ const id = c.jid.slice(WEB_JID_PREFIX.length);
1381
+ return {
1382
+ id,
1383
+ name: c.name,
1384
+ lastMessage: c.last_message_time,
1385
+ nameUpdatedAt: c.name_updated_at || 0,
1386
+ cwd: sessionCwds.get(id) || c.cwd || '',
1387
+ };
1388
+ });
1389
+ // Strip WEB_JID_PREFIX from stored JIDs so clients see plain session IDs
1390
+ const rawOrder = getWebSessionOrder();
1391
+ const order = rawOrder.map((jid) => jid.startsWith(WEB_JID_PREFIX)
1392
+ ? jid.slice(WEB_JID_PREFIX.length)
1393
+ : jid);
1394
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1395
+ res.end(JSON.stringify({ sessions, order }));
1396
+ return;
1397
+ }
1398
+ // POST /session-order — persist the user-defined session display order
1399
+ if (req.method === 'POST' && req.url === '/session-order') {
1400
+ collectBody(req, res, (body) => {
1401
+ try {
1402
+ const { order } = JSON.parse(body);
1403
+ if (!Array.isArray(order) ||
1404
+ !order.every((id) => typeof id === 'string')) {
1405
+ throw new Error('order must be an array of strings');
1406
+ }
1407
+ // Store as full JIDs internally
1408
+ setWebSessionOrder(order.map((id) => WEB_JID_PREFIX + id));
1409
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1410
+ res.end('{"ok":true}');
1411
+ }
1412
+ catch {
1413
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1414
+ res.end('{"error":"Bad request"}');
1415
+ }
1416
+ });
1417
+ return;
1418
+ }
1419
+ // POST /session-name — persist a custom session name to the DB
1420
+ if (req.method === 'POST' && req.url === '/session-name') {
1421
+ collectBody(req, res, (body) => {
1422
+ try {
1423
+ const { sid, name, nameUpdatedAt } = JSON.parse(body);
1424
+ if (sid && name && typeof name === 'string') {
1425
+ const safeName = sanitizeSessionName(name);
1426
+ if (safeName) {
1427
+ // Server-side guard: updateChatName only writes if nameUpdatedAt >= stored value
1428
+ const ts = typeof nameUpdatedAt === 'number'
1429
+ ? nameUpdatedAt
1430
+ : Date.now();
1431
+ updateChatName(WEB_JID_PREFIX + sid, safeName, ts);
1432
+ }
1433
+ }
1434
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1435
+ res.end('{"ok":true}');
1436
+ }
1437
+ catch {
1438
+ res.writeHead(400);
1439
+ res.end('Bad request');
1440
+ }
1441
+ });
1442
+ return;
1443
+ }
1444
+ // POST /delete-session — remove a session and its messages from the DB
1445
+ if (req.method === 'POST' && req.url === '/delete-session') {
1446
+ collectBody(req, res, (body) => {
1447
+ try {
1448
+ const { sid } = JSON.parse(body);
1449
+ if (sid && typeof sid === 'string') {
1450
+ deleteChat(WEB_JID_PREFIX + sid);
1451
+ registeredSessions.delete(sid);
1452
+ sseClients.delete(sid);
1453
+ sessionCwds.delete(sid);
1454
+ sessionTyping.delete(sid);
1455
+ sessionStatus.delete(sid);
1456
+ // Clean up per-session symlinks (user sessions only; cron has real dirs)
1457
+ if (sid !== CRON_SESSION_ID) {
1458
+ const folder = 'web-' + sid;
1459
+ try {
1460
+ fs.unlinkSync(path.join(GROUPS_DIR, folder));
1461
+ }
1462
+ catch {
1463
+ /* ignore */
1464
+ }
1465
+ try {
1466
+ fs.unlinkSync(path.join(DATA_DIR, 'sessions', folder));
1467
+ }
1468
+ catch {
1469
+ /* ignore */
1470
+ }
1471
+ }
1472
+ }
1473
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1474
+ res.end('{"ok":true}');
1475
+ }
1476
+ catch {
1477
+ res.writeHead(400);
1478
+ res.end('Bad request');
1479
+ }
1480
+ });
1481
+ return;
1482
+ }
1483
+ // POST /delete-message — delete a single message by id
1484
+ if (req.method === 'POST' && req.url === '/delete-message') {
1485
+ collectBody(req, res, (body) => {
1486
+ try {
1487
+ const { id } = JSON.parse(body);
1488
+ if (id && typeof id === 'string') {
1489
+ deleteMessage(id);
1490
+ }
1491
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1492
+ res.end('{"ok":true}');
1493
+ }
1494
+ catch {
1495
+ res.writeHead(400);
1496
+ res.end('Bad request');
1497
+ }
1498
+ });
1499
+ return;
1500
+ }
1501
+ // POST /clear-history — delete all messages for a session (keeps session entry)
1502
+ if (req.method === 'POST' && req.url === '/clear-history') {
1503
+ collectBody(req, res, (body) => {
1504
+ try {
1505
+ const { sid } = JSON.parse(body);
1506
+ if (sid && typeof sid === 'string') {
1507
+ clearChatMessages(WEB_JID_PREFIX + sid);
1508
+ }
1509
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1510
+ res.end('{"ok":true}');
1511
+ }
1512
+ catch {
1513
+ res.writeHead(400);
1514
+ res.end('Bad request');
1515
+ }
1516
+ });
1517
+ return;
1518
+ }
1519
+ // GET /history?sid=... — full conversation for a session (user + bot)
1520
+ if (req.method === 'GET' && req.url?.startsWith('/history')) {
1521
+ const sid = sidFromUrl(req.url);
1522
+ const jid = WEB_JID_PREFIX + sid;
1523
+ const messages = getConversation(jid, 500);
1524
+ const history = messages.map((m) => ({
1525
+ text: m.content,
1526
+ cls: m.is_bot_message || m.is_from_me ? 'bot' : 'user',
1527
+ id: m.id,
1528
+ }));
1529
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1530
+ res.end(JSON.stringify(history));
1531
+ return;
1532
+ }
1533
+ if (req.method === 'GET' && req.url?.startsWith('/events')) {
1534
+ const sessionId = sidFromUrl(req.url);
1535
+ // Set up SSE connection first, then register session (avoids interrupting the stream)
1536
+ res.writeHead(200, {
1537
+ 'Content-Type': 'text/event-stream',
1538
+ 'Cache-Control': 'no-cache',
1539
+ Connection: 'keep-alive',
1540
+ });
1541
+ res.write(':\n\n');
1542
+ const clients = getOrCreateClientSet(sessionId);
1543
+ clients.add(res);
1544
+ // Restore CWD from DB if not in memory (e.g. after server restart)
1545
+ if (!sessionCwds.has(sessionId)) {
1546
+ const chat = getAllChats().find((c) => c.jid === WEB_JID_PREFIX + sessionId);
1547
+ if (chat?.cwd)
1548
+ sessionCwds.set(sessionId, chat.cwd);
1549
+ }
1550
+ const cwd = sessionCwds.get(sessionId);
1551
+ if (cwd)
1552
+ res.write(`event: cwd\ndata: ${JSON.stringify(cwd)}\n\n`);
1553
+ // Always send current typing/status state so client syncs correctly on reconnect
1554
+ res.write(`event: typing\ndata: ${sessionTyping.get(sessionId) ? 'true' : 'false'}\n\n`);
1555
+ const status = sessionStatus.get(sessionId);
1556
+ res.write(`event: status\ndata: ${status ?? 'null'}\n\n`);
1557
+ req.on('close', () => clients.delete(res));
1558
+ // Register in DB after SSE is established — so other browsers can discover this session
1559
+ try {
1560
+ this.ensureSession(sessionId);
1561
+ }
1562
+ catch { }
1563
+ return;
1564
+ }
1565
+ if (req.method === 'POST' && req.url?.startsWith('/cancel')) {
1566
+ const sid = sidFromUrl(req.url);
1567
+ if (sid && this.onCancelRequest) {
1568
+ this.onCancelRequest(WEB_JID_PREFIX + sid);
1569
+ }
1570
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1571
+ res.end('{"ok":true}');
1572
+ return;
1573
+ }
1574
+ if (req.method === 'POST' && req.url === '/message') {
1575
+ collectBody(req, res, (body) => {
1576
+ try {
1577
+ const { content, sessionId = 'default', id: clientMsgId, } = JSON.parse(body);
1578
+ if (content && typeof content === 'string') {
1579
+ this.ensureSession(sessionId);
1580
+ const jid = WEB_JID_PREFIX + sessionId;
1581
+ // /cwd <path> shorthand: same effect as "switching to folder: <path>"
1582
+ const cwdCmdMatch = content.match(/^\/cwd(?:\s+(.*))?$/i);
1583
+ if (cwdCmdMatch) {
1584
+ const cwd = (cwdCmdMatch[1] || '').trim();
1585
+ setCwd(sessionId, cwd);
1586
+ broadcastToSession(sessionId, 'cwd', JSON.stringify(cwd));
1587
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1588
+ res.end('{"ok":true}');
1589
+ return;
1590
+ }
1591
+ // /pwd: reply immediately with the current CWD, no agent needed
1592
+ if (/^\/pwd\s*$/i.test(content)) {
1593
+ const cwd = sessionCwds.get(sessionId) ?? '';
1594
+ const reply = cwd
1595
+ ? `Current working folder: \`${cwd}\``
1596
+ : 'Working folder: workspace root';
1597
+ const botTs = new Date().toISOString();
1598
+ const botMsgId = `web-bot-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
1599
+ const botMsg = {
1600
+ id: botMsgId,
1601
+ chat_jid: jid,
1602
+ sender: 'bot',
1603
+ sender_name: 'NanoClaw',
1604
+ content: reply,
1605
+ timestamp: botTs,
1606
+ is_from_me: true,
1607
+ is_bot_message: true,
1608
+ };
1609
+ try {
1610
+ storeMessage(botMsg);
1611
+ }
1612
+ catch { }
1613
+ broadcastToSession(sessionId, 'message', JSON.stringify({ text: reply, id: botMsgId }));
1614
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1615
+ res.end('{"ok":true}');
1616
+ return;
1617
+ }
1618
+ const cwdMatch = content.match(/^switching to folder:\s*(.+)$/i);
1619
+ if (cwdMatch) {
1620
+ const cwd = cwdMatch[1].trim();
1621
+ setCwd(sessionId, cwd);
1622
+ broadcastToSession(sessionId, 'cwd', JSON.stringify(cwd));
1623
+ }
1624
+ // Use client-provided ID if valid (allows delete button to work immediately),
1625
+ // otherwise generate one server-side.
1626
+ const msgId = typeof clientMsgId === 'string' &&
1627
+ /^[\w-]{1,80}$/.test(clientMsgId)
1628
+ ? clientMsgId
1629
+ : `web-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
1630
+ const msg = {
1631
+ id: msgId,
1632
+ chat_jid: jid,
1633
+ sender: 'user@web',
1634
+ sender_name: 'User',
1635
+ content: content.trim(),
1636
+ timestamp: new Date().toISOString(),
1637
+ is_from_me: false,
1638
+ is_bot_message: false,
1639
+ };
1640
+ try {
1641
+ storeMessage(msg); // persist to DB for cross-browser history
1642
+ storeChatMetadata(jid, msg.timestamp); // keep chats.last_message_time current for unread detection
1643
+ }
1644
+ catch { }
1645
+ this.onMessage(jid, msg);
1646
+ }
1647
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1648
+ res.end('{"ok":true}');
1649
+ }
1650
+ catch {
1651
+ res.writeHead(400);
1652
+ res.end('Bad request');
1653
+ }
1654
+ });
1655
+ return;
1656
+ }
1657
+ if (req.method === 'POST' && req.url === '/cwd') {
1658
+ collectBody(req, res, (body) => {
1659
+ try {
1660
+ const { cwd, sessionId = 'default' } = JSON.parse(body);
1661
+ if (typeof cwd === 'string') {
1662
+ // Validate that the resolved path stays within the workspace
1663
+ const workspace = process.cwd();
1664
+ const resolved = path.resolve(workspace, cwd);
1665
+ if (resolved !== workspace &&
1666
+ !resolved.startsWith(workspace + path.sep)) {
1667
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1668
+ res.end('{"error":"CWD outside workspace"}');
1669
+ return;
1670
+ }
1671
+ const safeCwd = path.relative(workspace, resolved);
1672
+ setCwd(sessionId, safeCwd);
1673
+ broadcastToSession(sessionId, 'cwd', JSON.stringify(safeCwd));
1674
+ }
1675
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1676
+ res.end('{"ok":true}');
1677
+ }
1678
+ catch {
1679
+ res.writeHead(400);
1680
+ res.end('Bad request');
1681
+ }
1682
+ });
1683
+ return;
1684
+ }
1685
+ if (req.method === 'GET' && req.url?.split('?')[0] === '/pwd') {
1686
+ const sid = sidFromUrl(req.url);
1687
+ const cwd = sessionCwds.get(sid) ?? '';
1688
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1689
+ res.end(JSON.stringify({ cwd }));
1690
+ return;
1691
+ }
1692
+ if (req.method === 'POST' && req.url === '/upload') {
1693
+ // File data is Base64-encoded (~33 % overhead → ~7.5 MB effective file size).
1694
+ collectBody(req, res, (body) => {
1695
+ try {
1696
+ const { filename, data, sessionId = 'default', } = JSON.parse(body);
1697
+ if (!filename || !data)
1698
+ throw new Error('Missing fields');
1699
+ const safeName = path.basename(filename);
1700
+ const groupDir = path.join(process.cwd(), 'groups', GROUP_FOLDER);
1701
+ const cwd = sessionCwds.get(sessionId) ?? '';
1702
+ const dir = cwd ? path.join(groupDir, cwd) : groupDir;
1703
+ // Verify upload directory stays within groupDir (defense-in-depth)
1704
+ if (dir !== groupDir && !dir.startsWith(groupDir + path.sep)) {
1705
+ throw new Error('Upload path outside workspace');
1706
+ }
1707
+ fs.mkdirSync(dir, { recursive: true });
1708
+ const filePath = path.join(dir, safeName);
1709
+ fs.writeFileSync(filePath, Buffer.from(data, 'base64'));
1710
+ const relPath = path.relative(groupDir, filePath);
1711
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1712
+ res.end(JSON.stringify({ ok: true, path: relPath }));
1713
+ }
1714
+ catch (e) {
1715
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1716
+ res.end(JSON.stringify({ ok: false, error: e.message }));
1717
+ }
1718
+ }, MAX_UPLOAD_BODY_SIZE);
1719
+ return;
1720
+ }
1721
+ // Static assets: favicon.ico and apple-touch-icon.png from group folder
1722
+ const staticFiles = {
1723
+ '/favicon.ico': 'image/x-icon',
1724
+ '/favicon.png': 'image/png',
1725
+ '/apple-touch-icon.png': 'image/png',
1726
+ };
1727
+ const urlPath = req.url?.split('?')[0] ?? '';
1728
+ if (req.method === 'GET' && staticFiles[urlPath]) {
1729
+ const filePath = path.join(process.cwd(), 'groups', GROUP_FOLDER, urlPath.slice(1));
1730
+ try {
1731
+ const data = fs.readFileSync(filePath);
1732
+ res.writeHead(200, {
1733
+ 'Content-Type': staticFiles[urlPath],
1734
+ 'Cache-Control': 'max-age=3600',
1735
+ });
1736
+ res.end(data);
1737
+ }
1738
+ catch {
1739
+ res.writeHead(404);
1740
+ res.end('Not found');
1741
+ }
1742
+ return;
1743
+ }
1744
+ res.writeHead(404);
1745
+ res.end('Not found');
1746
+ });
1747
+ await new Promise((resolve, reject) => {
1748
+ this.server.listen(PORT, HOST, () => resolve());
1749
+ this.server.on('error', reject);
1750
+ });
1751
+ this.connected = true;
1752
+ logger.info({ port: PORT }, 'Web chat channel listening');
1753
+ // Ensure dedicated cron session exists (low timestamp so user renames always win)
1754
+ try {
1755
+ updateChatName(WEB_JID_PREFIX + CRON_SESSION_ID, CRON_SESSION_NAME, 1);
1756
+ this.ensureSession(CRON_SESSION_ID);
1757
+ }
1758
+ catch { }
1759
+ // Remove symlinks for sessions that no longer exist in the DB
1760
+ try {
1761
+ this.cleanupOrphanedSessionLinks();
1762
+ }
1763
+ catch {
1764
+ /* non-critical */
1765
+ }
1766
+ }
1767
+ async sendMessage(jid, text) {
1768
+ const sessionId = sessionIdFromJid(jid);
1769
+ const topicMatch = text.match(/^switching to folder:\s*(.+)$/im);
1770
+ if (topicMatch) {
1771
+ const cwd = topicMatch[1].trim();
1772
+ setCwd(sessionId, cwd);
1773
+ broadcastToSession(sessionId, 'cwd', JSON.stringify(cwd));
1774
+ }
1775
+ // Persist bot response to DB so history works across browsers/windows
1776
+ const botTs = new Date().toISOString();
1777
+ const botMsgId = `web-bot-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
1778
+ try {
1779
+ storeMessage({
1780
+ id: botMsgId,
1781
+ chat_jid: jid,
1782
+ sender: 'bot@web',
1783
+ sender_name: 'Nemo',
1784
+ content: text,
1785
+ timestamp: botTs,
1786
+ is_from_me: true,
1787
+ is_bot_message: true,
1788
+ });
1789
+ // storeMessage only writes to the messages table; update chats.last_message_time
1790
+ // so that mergeServerSessions() can detect this session as unread in other browsers/tabs.
1791
+ storeChatMetadata(jid, botTs);
1792
+ }
1793
+ catch { }
1794
+ // Broadcast {text, id} so the client can assign a trash button with the correct DB id
1795
+ broadcastToSession(sessionId, 'message', JSON.stringify({ text, id: botMsgId }));
1796
+ }
1797
+ async setTyping(jid, isTyping) {
1798
+ const sessionId = sessionIdFromJid(jid);
1799
+ if (isTyping) {
1800
+ sessionTyping.set(sessionId, true);
1801
+ }
1802
+ else {
1803
+ sessionTyping.delete(sessionId);
1804
+ // Also clear status so reconnecting clients don't see a stale tool-use line
1805
+ sessionStatus.delete(sessionId);
1806
+ broadcastToSession(sessionId, 'status', 'null');
1807
+ }
1808
+ broadcastToSession(sessionId, 'typing', String(isTyping));
1809
+ }
1810
+ setStatus(jid, tool, inputSnippet) {
1811
+ const sessionId = sessionIdFromJid(jid);
1812
+ const payload = tool
1813
+ ? JSON.stringify({ tool, input: inputSnippet ?? null })
1814
+ : 'null';
1815
+ if (tool)
1816
+ sessionStatus.set(sessionId, payload);
1817
+ else
1818
+ sessionStatus.delete(sessionId);
1819
+ broadcastToSession(sessionId, 'status', payload);
1820
+ }
1821
+ isConnected() {
1822
+ return this.connected;
1823
+ }
1824
+ ownsJid(jid) {
1825
+ return jid.startsWith(WEB_JID_PREFIX);
1826
+ }
1827
+ async disconnect() {
1828
+ this.connected = false;
1829
+ for (const clients of sseClients.values()) {
1830
+ for (const client of clients)
1831
+ client.end();
1832
+ }
1833
+ sseClients.clear();
1834
+ await new Promise((resolve) => {
1835
+ if (this.server)
1836
+ this.server.close(() => resolve());
1837
+ else
1838
+ resolve();
1839
+ });
1840
+ }
1841
+ }
1842
+ registerChannel('web', (opts) => new WebChannel(opts));
1843
+ //# sourceMappingURL=web.js.map