@rozek/nanoclaw 1.2.17

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