@rozek/nanoclaw 0.0.1

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