@inetafrica/open-claudia 3.0.10 → 3.0.13

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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## v3.0.13 — chat with the bot from its own dashboard
4
+
5
+ - **The web dashboard gains a Chat tab — a real channel, not a side door.** A new in-process `WebAdapter` (`channels/web/`) registers as a first-class channel whenever the web UI is up: messages typed in the dashboard flow through the same router, identity, packs, transcripts, and approval pipeline as Telegram/Kazee/voice, and the bot's replies (including inline approval buttons, files, voice notes, live typing) stream back over `/api/chat/stream` (SSE with history replay on connect). New session-gated routes: `GET /api/chat/stream`, `POST /api/chat/send`, `POST /api/chat/action`, `GET /api/chat/media/<id>`. The channel is fixed single-owner (`web-owner`): the dashboard session already gates every call, so `access.js`/`identity.js` authorize it as the owner and — under unified identity — it resolves to the owner's canonical brain like every other owner channel.
6
+ - **Platform SSO: `POST /api/otp/mint` (control-token only).** AgentSpace can mint a one-time magic-link token (`/otp/<token>`, 10-min TTL, single-use — the existing `core/web-otp` machinery behind `/dashboard`) over `BOT_CONTROL_TOKEN`. Dashboard sessions get a 403: they are already logged in and gain nothing, so the surface stays strictly machine-to-machine.
7
+ - **SSE unsubscribe bug fixed before it shipped.** Cleanup was hooked on `req.on("close")`, which in modern Node fires as soon as the GET body completes — every stream subscriber was torn down instantly after the history frame. Cleanup now hooks `res.on("close")` (actual connection teardown), verified by an end-to-end live-frame test.
8
+ - **Agent-space / OpenClaw:** no new deps, no new env, no schema change; Docker image builds identically (FROM the pre-baked `:base`). Existing pods pick this up only on their next approved image upgrade; new pods get it immediately. `channels/` already ships in `files`, so the adapter rides the existing package layout.
9
+
3
10
  ## v3.0.10 — Kazee-only pods are first-class
4
11
 
5
12
  - **/doctor no longer demands Telegram env on a Kazee-only install.** `health.js` required `TELEGRAM_BOT_TOKEN`/`TELEGRAM_CHAT_ID` unconditionally — a pod provisioned with `CHANNELS=kazee` failed its env check despite being perfectly configured. Required keys are now derived from `CHANNELS` (default `telegram`, preserving legacy behaviour): telegram installs require the Telegram pair, kazee installs require `KAZEE_BOT_TOKEN`, and `WORKSPACE` stays universal.
package/bot.js CHANGED
@@ -74,8 +74,14 @@ registry.setHandlers({ onMessage, onAction });
74
74
  const adapters = registry.bootstrap();
75
75
 
76
76
  if (adapters.length === 0) {
77
- console.error("No channels configured. Set CHANNELS=telegram and TELEGRAM_BOT_TOKEN/TELEGRAM_CHAT_ID, or CHANNELS=kazee with KAZEE_URL/KAZEE_BOT_TOKEN.");
78
- process.exit(1);
77
+ if (process.env.WEB_UI === "true") {
78
+ // Channel-less pods are valid: the dashboard (and AgentSpace) can attach
79
+ // telegram/kazee later, and addAdapter() boots them without a restart.
80
+ console.log("No channels configured yet — waiting for one to be attached via the dashboard.");
81
+ } else {
82
+ console.error("No channels configured. Set CHANNELS=telegram and TELEGRAM_BOT_TOKEN/TELEGRAM_CHAT_ID, or CHANNELS=kazee with KAZEE_URL/KAZEE_BOT_TOKEN.");
83
+ process.exit(1);
84
+ }
79
85
  }
80
86
 
81
87
  // ── Graceful shutdown & error handling ─────────────────────────────
@@ -265,6 +271,10 @@ setInterval(checkForUpdates, 5 * 60 * 1000);
265
271
  if (process.env.WEB_UI === "true") {
266
272
  try {
267
273
  require("./web.js").startWebServer();
274
+ // Dashboard chat rides a real channel adapter so it gets the full
275
+ // pipeline (identity, packs, transcripts, approval buttons).
276
+ const webChat = await registry.addAdapter({ type: "web", id: "web" }, { publicCommands: publicCommands() });
277
+ if (!webChat.ok) console.error("Web chat adapter failed:", webChat.error);
268
278
  } catch (e) {
269
279
  console.error("Web UI start failed:", e.message);
270
280
  }
@@ -290,16 +300,22 @@ setInterval(checkForUpdates, 5 * 60 * 1000);
290
300
 
291
301
  // Notify owner that bot is back online via every adapter that knows
292
302
  // an owner channel. Kazee skipped: ownerUserId is a user id, not a
293
- // chat_id, and chat-central has no DM-by-userId send. Owner DM still
294
- // wakes the bot fine this is just the proactive "I'm back" ping.
303
+ // chat_id boot onboarding below opens the DM when outreach is needed.
304
+ // Skipped entirely pre-onboarding: a fresh pod's first words should be
305
+ // the onboarding greeting, not "Back online".
295
306
  for (const a of adapters) {
296
307
  try {
297
- if (a.type === "telegram" && CHAT_ID) {
308
+ if (a.type === "telegram" && CHAT_ID && isOnboarded()) {
298
309
  await a.send(CHAT_ID, `Back online and ready! Running v${CURRENT_VERSION}.`);
299
310
  }
300
311
  } catch (e) {}
301
312
  }
302
313
 
314
+ // Proactive first-boot outreach: greet an env-provisioned owner, guide them
315
+ // through /login, then run the LLM onboarding interview once authed.
316
+ try { await require("./core/boot-onboarding").initBootOnboarding(adapters); }
317
+ catch (e) { console.error("boot onboarding failed:", e.message); }
318
+
303
319
  // Surface jobs the provider migration disabled. Announce once per job:
304
320
  // marked only after a successful send, so a down adapter retries next boot.
305
321
  try {
@@ -264,6 +264,30 @@ class KazeeAdapter {
264
264
  return null;
265
265
  }
266
266
 
267
+ // Get-or-create the 1:1 chat between the bot and a user, returning its
268
+ // chatId. chat-central's createChat returns the existing DM when one
269
+ // already exists, so this is idempotent. Requires chats.create on the bot
270
+ // token — callers must treat null as "outreach unavailable", not fatal.
271
+ async openDirectChat(userId) {
272
+ if (!this.botUserId || !userId) return null;
273
+ try {
274
+ const res = await this._request("POST", "/chat", {
275
+ participants: { members: [String(this.botUserId), String(userId)] },
276
+ chatName: "Direct Message",
277
+ chatType: "chat",
278
+ isGroupChat: false,
279
+ });
280
+ if (res && res.success === false) {
281
+ console.error("Kazee openDirectChat rejected:", res.error || "unknown error");
282
+ return null;
283
+ }
284
+ return res && res.chatId ? String(res.chatId) : null;
285
+ } catch (e) {
286
+ console.error("Kazee openDirectChat error:", e.message);
287
+ return null;
288
+ }
289
+ }
290
+
267
291
  async edit(channelId, messageId, text, opts = {}) {
268
292
  const buttons = this._normalizeKeyboard(opts.keyboard);
269
293
  const body = { content: normalize(text) };
@@ -0,0 +1,263 @@
1
+ // WebAdapter — the dashboard chat channel.
2
+ //
3
+ // Unlike Telegram/Kazee/Voice this adapter owns no transport: web.js runs
4
+ // in the same process and feeds it directly. Inbound: postText/postAction
5
+ // called by the /api/chat endpoints (session- or control-token-gated).
6
+ // Outbound: send/edit/typing broadcast frames to subscribed SSE clients
7
+ // and into a small in-memory history ring so a page reload can replay the
8
+ // conversation since boot. Everything routes through the same core
9
+ // handlers as the other channels, so web chat has the full agent
10
+ // capability set — including approval buttons.
11
+ //
12
+ // Security: single-owner channel. web.js only calls in after its own auth
13
+ // (dashboard session cookie or BOT_CONTROL_TOKEN), so every envelope
14
+ // carries the fixed owner id and access.js authorizes it as the owner.
15
+
16
+ const fs = require("fs");
17
+ const path = require("path");
18
+ const crypto = require("crypto");
19
+ const { canonicalForChannel } = require("../../core/identity");
20
+ const { inlineKeyboardToPortable } = require("../types");
21
+
22
+ const HISTORY_LIMIT = 200;
23
+ const MEDIA_TTL_MS = 60 * 60 * 1000;
24
+
25
+ const WEB_OWNER_ID = "web-owner";
26
+
27
+ class WebAdapter {
28
+ constructor({ id = "web" } = {}) {
29
+ this.id = id;
30
+ this.type = "web";
31
+ this.ownerUserId = WEB_OWNER_ID;
32
+ // Single conversation: the channel id is the owner identity.
33
+ this.channelId = WEB_OWNER_ID;
34
+ this._listeners = { message: new Set(), action: new Set() };
35
+ this._subscribers = new Set();
36
+ this._history = [];
37
+ this._media = new Map(); // id -> { path, mime, fileName, keep, expires }
38
+ this._commands = [];
39
+ this._sweepTimer = null;
40
+ }
41
+
42
+ on(event, fn) {
43
+ if (!this._listeners[event]) return () => {};
44
+ this._listeners[event].add(fn);
45
+ return () => this._listeners[event].delete(fn);
46
+ }
47
+
48
+ _emit(event, envelope) {
49
+ for (const fn of this._listeners[event] || []) {
50
+ try { Promise.resolve(fn(envelope)).catch((e) => console.error(`web ${event} handler:`, e.message)); }
51
+ catch (e) { console.error(`web ${event} handler:`, e.message); }
52
+ }
53
+ }
54
+
55
+ async start() {
56
+ this._sweepTimer = setInterval(() => this._sweepMedia(), 5 * 60 * 1000);
57
+ if (this._sweepTimer.unref) this._sweepTimer.unref();
58
+ }
59
+
60
+ async stop() {
61
+ if (this._sweepTimer) clearInterval(this._sweepTimer);
62
+ this._subscribers.clear();
63
+ }
64
+
65
+ // ── inbound (called by web.js /api/chat routes) ─────────────────
66
+
67
+ postText(text) {
68
+ const body = String(text || "");
69
+ if (!body.trim()) return null;
70
+ const messageId = this._mkId("t");
71
+ // Echo into history/stream so every connected tab sees the user's turn.
72
+ this._pushFrame({ kind: "message", role: "user", messageId, text: body, ts: Date.now() });
73
+ this._emit("message", {
74
+ adapter: this,
75
+ channelId: this.channelId,
76
+ canonicalUserId: canonicalForChannel("web", this.channelId),
77
+ userId: this.ownerUserId,
78
+ type: body.trim().startsWith("/") ? "command" : "text",
79
+ text: body,
80
+ messageId,
81
+ from: { id: this.ownerUserId, name: "Owner", username: "" },
82
+ raw: { source: "web" },
83
+ });
84
+ return messageId;
85
+ }
86
+
87
+ postAction({ id, payload, messageId } = {}) {
88
+ const p = String(payload != null ? payload : (id != null ? id : ""));
89
+ if (!p) return null;
90
+ const callbackId = this._mkId("cb");
91
+ this._emit("action", {
92
+ adapter: this,
93
+ channelId: this.channelId,
94
+ canonicalUserId: canonicalForChannel("web", this.channelId),
95
+ userId: this.ownerUserId,
96
+ type: "action",
97
+ messageId,
98
+ from: { id: this.ownerUserId, name: "Owner", username: "" },
99
+ action: {
100
+ buttonId: String(id != null ? id : p),
101
+ payload: p,
102
+ sourceMessageId: messageId,
103
+ callbackId,
104
+ },
105
+ raw: { source: "web" },
106
+ });
107
+ return callbackId;
108
+ }
109
+
110
+ // ── stream plumbing (web.js SSE endpoint) ───────────────────────
111
+
112
+ subscribe(fn) {
113
+ this._subscribers.add(fn);
114
+ return () => this._subscribers.delete(fn);
115
+ }
116
+
117
+ history() {
118
+ return this._history.slice();
119
+ }
120
+
121
+ getMedia(id) {
122
+ const entry = this._media.get(id);
123
+ if (!entry || !fs.existsSync(entry.path)) return null;
124
+ return entry;
125
+ }
126
+
127
+ _pushFrame(frame) {
128
+ this._history.push(frame);
129
+ if (this._history.length > HISTORY_LIMIT) this._history.splice(0, this._history.length - HISTORY_LIMIT);
130
+ for (const fn of this._subscribers) {
131
+ try { fn(frame); } catch (e) {}
132
+ }
133
+ }
134
+
135
+ // ── outbound contract (called by core/io.js) ────────────────────
136
+
137
+ _normalizeKeyboard(keyboard) {
138
+ if (!keyboard) return null;
139
+ if (keyboard.buttons) return keyboard.buttons;
140
+ if (keyboard.inline_keyboard) return inlineKeyboardToPortable(keyboard.inline_keyboard);
141
+ return null;
142
+ }
143
+
144
+ async send(channelId, text, opts = {}) {
145
+ const messageId = this._mkId("a");
146
+ this._pushFrame({
147
+ kind: "message",
148
+ role: "assistant",
149
+ messageId,
150
+ text: text || "",
151
+ buttons: this._normalizeKeyboard(opts.keyboard),
152
+ replyTo: opts.replyTo || null,
153
+ ts: Date.now(),
154
+ });
155
+ return messageId;
156
+ }
157
+
158
+ async edit(channelId, messageId, text, opts = {}) {
159
+ const frame = {
160
+ kind: "edit",
161
+ messageId,
162
+ text: text || "",
163
+ buttons: this._normalizeKeyboard(opts.keyboard),
164
+ ts: Date.now(),
165
+ };
166
+ // Keep history coherent: apply the edit to the stored message so a
167
+ // reload shows the final text, not the first streamed fragment.
168
+ const original = this._history.find((f) => f.kind === "message" && f.messageId === messageId);
169
+ if (original) { original.text = frame.text; original.buttons = frame.buttons; }
170
+ this._pushFrame(frame);
171
+ }
172
+
173
+ async delete(channelId, messageId) {
174
+ // Prune the message AND its edit frames so a reload replays nothing stale.
175
+ this._history = this._history.filter(
176
+ (f) => !((f.kind === "message" || f.kind === "edit") && f.messageId === messageId)
177
+ );
178
+ this._pushFrame({ kind: "delete", messageId, ts: Date.now() });
179
+ }
180
+
181
+ async sendVoice(channelId, audioPath) {
182
+ try {
183
+ const id = this._registerMedia(audioPath, this._guessMime(audioPath), path.basename(audioPath));
184
+ this._pushFrame({ kind: "voice", messageId: this._mkId("v"), url: `/api/chat/media/${id}`, ts: Date.now() });
185
+ return true;
186
+ } catch (e) {
187
+ return false;
188
+ }
189
+ }
190
+
191
+ async sendPhoto(channelId, filePath, caption) { return this.sendFile(channelId, filePath, caption); }
192
+
193
+ async sendFile(channelId, filePath, caption) {
194
+ try {
195
+ const fileName = path.basename(filePath);
196
+ const id = this._registerMedia(filePath, this._guessMime(fileName), fileName, /* keep */ true);
197
+ this._pushFrame({
198
+ kind: "file",
199
+ messageId: this._mkId("f"),
200
+ url: `/api/chat/media/${id}`,
201
+ fileName,
202
+ caption: caption || "",
203
+ ts: Date.now(),
204
+ });
205
+ return true;
206
+ } catch (e) {
207
+ return false;
208
+ }
209
+ }
210
+
211
+ async typing(channelId) {
212
+ // Ephemeral — streamed to live clients but never recorded in history.
213
+ for (const fn of this._subscribers) {
214
+ try { fn({ kind: "typing", ts: Date.now() }); } catch (e) {}
215
+ }
216
+ }
217
+
218
+ async downloadMedia(media) {
219
+ if (!media) return null;
220
+ return media.fileId || null;
221
+ }
222
+
223
+ async registerCommands(commands) {
224
+ this._commands = (commands || [])
225
+ .filter((c) => c && c.name)
226
+ .map((c) => ({ name: String(c.name).replace(/^\//, ""), description: String(c.description || ""), args: typeof c.args === "string" ? c.args : "" }));
227
+ }
228
+
229
+ // ── media store ─────────────────────────────────────────────────
230
+
231
+ _registerMedia(filePath, mime, fileName, keep = false) {
232
+ const id = crypto.randomBytes(9).toString("hex");
233
+ this._media.set(id, { path: filePath, mime, fileName, keep, expires: Date.now() + MEDIA_TTL_MS });
234
+ return id;
235
+ }
236
+
237
+ _sweepMedia() {
238
+ const now = Date.now();
239
+ for (const [id, entry] of this._media) {
240
+ if (entry.expires <= now) {
241
+ this._media.delete(id);
242
+ if (!entry.keep) { try { fs.unlinkSync(entry.path); } catch (e) {} }
243
+ }
244
+ }
245
+ }
246
+
247
+ // ── helpers ─────────────────────────────────────────────────────
248
+
249
+ _mkId(prefix) { return `${prefix}-${Date.now()}-${crypto.randomBytes(4).toString("hex")}`; }
250
+
251
+ _guessMime(fileName) {
252
+ const ext = path.extname(fileName).toLowerCase();
253
+ const map = {
254
+ ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif",
255
+ ".webp": "image/webp", ".ogg": "audio/ogg", ".mp3": "audio/mpeg", ".m4a": "audio/mp4",
256
+ ".wav": "audio/wav", ".mp4": "video/mp4", ".pdf": "application/pdf", ".txt": "text/plain",
257
+ ".json": "application/json", ".csv": "text/csv",
258
+ };
259
+ return map[ext] || "application/octet-stream";
260
+ }
261
+ }
262
+
263
+ module.exports = { WebAdapter, WEB_OWNER_ID };
package/core/access.js CHANGED
@@ -17,6 +17,10 @@ function transportOwnerUserId(transport) {
17
17
  // The voice channel is single-owner: the bearer token gates the connection,
18
18
  // and every envelope carries this fixed owner id, so it authorizes as owner.
19
19
  if (transport === "voice") return config.VOICE_OWNER_USER_ID || "voice-owner";
20
+ // The web channel is single-owner: the dashboard session (password or
21
+ // magic-link) gates every call into the adapter, so its fixed owner id
22
+ // authorizes as owner.
23
+ if (transport === "web") return "web-owner";
20
24
  return "";
21
25
  }
22
26
 
@@ -13,6 +13,7 @@ const { setAdapters } = require("./scheduler");
13
13
  const { TelegramAdapter } = require("../channels/telegram/adapter");
14
14
  const { KazeeAdapter } = require("../channels/kazee/adapter");
15
15
  const { VoiceAdapter } = require("../channels/voice/adapter");
16
+ const { WebAdapter } = require("../channels/web/adapter");
16
17
 
17
18
  const adapters = [];
18
19
  let messageHandler = null;
@@ -22,6 +23,7 @@ function createAdapter(spec) {
22
23
  if (spec.type === "telegram") return new TelegramAdapter({ id: spec.id, ...spec.opts });
23
24
  if (spec.type === "kazee") return new KazeeAdapter({ id: spec.id, ...spec.opts });
24
25
  if (spec.type === "voice") return new VoiceAdapter({ id: spec.id, ...spec.opts });
26
+ if (spec.type === "web") return new WebAdapter({ id: spec.id, ...spec.opts });
25
27
  console.error(`Unknown adapter type: ${spec.type}`);
26
28
  return null;
27
29
  }
@@ -0,0 +1,212 @@
1
+ // Proactive first-boot outreach. A provisioned pod knows its owner from env
2
+ // (TELEGRAM_CHAT_ID / KAZEE_OWNER_USER_ID) but historically sat silent until
3
+ // the owner messaged first. Instead: greet the owner at boot, walk them
4
+ // through connecting a provider (/login), and once one is authenticated run
5
+ // an LLM-led interview that captures the bot's role and the owner's profile
6
+ // into the soul file. Flags persist in CONFIG_DIR so restarts and upgrades
7
+ // never re-ping; no env-provisioned owner means stay quiet and let /auth
8
+ // claim ownership exactly as before.
9
+
10
+ "use strict";
11
+
12
+ const fs = require("fs");
13
+ const path = require("path");
14
+
15
+ const { CONFIG_DIR, WORKSPACE, SOUL_FILE, config, saveEnvKey } = require("./config");
16
+ const { runInChat } = require("./context");
17
+ const { canonicalForChannel } = require("./identity");
18
+
19
+ const FLAGS_FILE = path.join(CONFIG_DIR, "boot-onboarding.json");
20
+ const AUTH_POLL_MS = 30 * 1000;
21
+
22
+ let authWatcher = null;
23
+
24
+ function readFlags() {
25
+ try { return JSON.parse(fs.readFileSync(FLAGS_FILE, "utf8")) || {}; }
26
+ catch (e) { return {}; }
27
+ }
28
+
29
+ function writeFlags(patch) {
30
+ const flags = { ...readFlags(), ...patch };
31
+ try { fs.writeFileSync(FLAGS_FILE, JSON.stringify(flags, null, 2)); }
32
+ catch (e) { console.error("boot-onboarding: could not persist flags:", e.message); }
33
+ return flags;
34
+ }
35
+
36
+ function isOnboarded() {
37
+ return config.ONBOARDED === "true";
38
+ }
39
+
40
+ // Authoritative auth check via the provider registry (probes CLI auth state,
41
+ // so API-key-provisioned pods count as authenticated — token presence alone
42
+ // would miss them). Registry caches healthy probes; negatives always re-probe.
43
+ function authenticatedProviderId() {
44
+ try {
45
+ const providers = require("./providers");
46
+ for (const provider of providers.listProviders()) {
47
+ try {
48
+ const status = providers.providerStatus(provider.id);
49
+ if (status.availability?.state === "available"
50
+ && status.compatibility?.state !== "incompatible"
51
+ && status.auth?.state === "authenticated") return provider.id;
52
+ } catch (e) { /* try the next provider */ }
53
+ }
54
+ } catch (e) {
55
+ console.error("boot-onboarding: provider status check failed:", e.message);
56
+ }
57
+ return null;
58
+ }
59
+
60
+ async function resolveOwnerChat(adapters) {
61
+ for (const a of adapters) {
62
+ if (a.type === "telegram" && a.ownerChatId) {
63
+ return { adapter: a, channelId: String(a.ownerChatId), userId: String(a.ownerChatId) };
64
+ }
65
+ }
66
+ for (const a of adapters) {
67
+ if (a.type === "kazee" && a.ownerUserId && typeof a.openDirectChat === "function") {
68
+ const chatId = await a.openDirectChat(a.ownerUserId);
69
+ if (chatId) return { adapter: a, channelId: String(chatId), userId: String(a.ownerUserId) };
70
+ console.error("boot-onboarding: could not open a Kazee DM with the owner — outreach skipped (bot token may lack chats.create).");
71
+ }
72
+ }
73
+ return null;
74
+ }
75
+
76
+ // The scope must land in the same state bucket as the owner's own replies:
77
+ // kazee envelopes key canonical identity on the sender's userId, telegram DMs
78
+ // on the chat id — mirror that here or the interview forks into a bucket the
79
+ // owner's replies never reach.
80
+ function chatScope(ownerChat) {
81
+ return {
82
+ adapter: ownerChat.adapter,
83
+ channelId: ownerChat.channelId,
84
+ canonicalUserId: canonicalForChannel(ownerChat.adapter.type, ownerChat.userId),
85
+ userId: ownerChat.userId,
86
+ transport: ownerChat.adapter.type,
87
+ raw: null,
88
+ };
89
+ }
90
+
91
+ function greetingMessage() {
92
+ return [
93
+ "👋 Hi! I'm your new agent — I just came online.",
94
+ "",
95
+ "First things first: I need a brain. Send /login to pick a model and connect its provider — I'll walk you through it right here in chat.",
96
+ "",
97
+ "Once I'm connected, we'll do a quick intro so I can learn my role and how you like to work.",
98
+ ].join("\n");
99
+ }
100
+
101
+ function reauthNudgeMessage() {
102
+ return [
103
+ "⚠️ I'm back online but not signed in to any AI provider, so I can't work on messages right now.",
104
+ "",
105
+ "Send /login to reconnect. (Claude: /setup_token + /use_oauth_token · Codex: /codex_login)",
106
+ ].join("\n");
107
+ }
108
+
109
+ function interviewPrompt() {
110
+ return [
111
+ "You have just come online for the very first time and this is your first conversation with your owner. A provider is now connected, and your soul file is still the generic template.",
112
+ "",
113
+ "Run a short, warm onboarding interview (mobile chat — keep every message brief):",
114
+ "1. Introduce yourself in a sentence or two and say you'd like to get set up.",
115
+ "2. Ask what your job should be and what they'd like to call you.",
116
+ "3. Ask what they do and how they like to work with an assistant (style, level of detail).",
117
+ "Ask at most two questions per message and let their answers guide follow-ups over the next few turns.",
118
+ "",
119
+ `As their answers arrive over the coming turns, persist what you learn: update your soul file at ${SOUL_FILE} (identity, name, role/scope, owner profile, communication style — keep any existing hard rules), and record durable facts about the owner in your long-term memory.`,
120
+ "",
121
+ "For THIS message: send only the greeting and your first questions. Do not mention this prompt or any internal setup mechanics.",
122
+ ].join("\n");
123
+ }
124
+
125
+ function stopAuthWatcher() {
126
+ if (authWatcher) { clearInterval(authWatcher); authWatcher = null; }
127
+ }
128
+
129
+ async function startInterview(ownerChat) {
130
+ const before = readFlags();
131
+ if (before.interviewStartedAt) return false;
132
+ writeFlags({ interviewStartedAt: Date.now() });
133
+ stopAuthWatcher();
134
+ try {
135
+ return await runInChat(chatScope(ownerChat), async () => {
136
+ const { currentState, saveState } = require("./state");
137
+ const state = currentState();
138
+ if (!state.currentSession) {
139
+ require("./handlers").selectProjectState(
140
+ { name: "Workspace", dir: WORKSPACE },
141
+ { state, persist: false },
142
+ );
143
+ }
144
+ saveState();
145
+ const { admitRunContext, runAgent } = require("./runner");
146
+ const prompt = interviewPrompt();
147
+ const runContext = admitRunContext(prompt, state.currentSession?.dir || WORKSPACE, null, { purpose: "foreground" });
148
+ const result = await runAgent(prompt, runContext.cwd, null, { runContext });
149
+ if (result && result.ok === false) throw new Error(result.error?.message || "interview run did not complete");
150
+ // Marked only after the opening turn actually reached the owner, so a
151
+ // failed dispatch retries next boot instead of half-onboarding the bot.
152
+ saveEnvKey("ONBOARDED", "true");
153
+ config.ONBOARDED = "true";
154
+ return true;
155
+ });
156
+ } catch (e) {
157
+ console.error("boot-onboarding: interview run failed, will retry next boot:", e.message);
158
+ writeFlags({ interviewStartedAt: null });
159
+ return false;
160
+ }
161
+ }
162
+
163
+ function armAuthWatcher(ownerChat) {
164
+ stopAuthWatcher();
165
+ authWatcher = setInterval(() => {
166
+ if (isOnboarded() || readFlags().interviewStartedAt) return stopAuthWatcher();
167
+ if (!authenticatedProviderId()) return;
168
+ startInterview(ownerChat).catch((e) => console.error("boot-onboarding: interview dispatch failed:", e.message));
169
+ }, AUTH_POLL_MS);
170
+ if (typeof authWatcher.unref === "function") authWatcher.unref();
171
+ }
172
+
173
+ async function initBootOnboarding(adapters) {
174
+ const ownerChat = await resolveOwnerChat(adapters);
175
+ if (!ownerChat) return;
176
+
177
+ const flags = readFlags();
178
+ const authedProvider = authenticatedProviderId();
179
+
180
+ if (isOnboarded()) {
181
+ if (authedProvider) {
182
+ // Healthy boot: re-arm the one-shot nudge for a future auth loss.
183
+ if (flags.unauthNudgedAt) writeFlags({ unauthNudgedAt: null });
184
+ return;
185
+ }
186
+ if (!flags.unauthNudgedAt) {
187
+ const sent = await runInChat(chatScope(ownerChat), () => require("./io").send(reauthNudgeMessage()));
188
+ if (sent?.ok) writeFlags({ unauthNudgedAt: Date.now() });
189
+ }
190
+ return;
191
+ }
192
+
193
+ if (authedProvider) {
194
+ await startInterview(ownerChat);
195
+ return;
196
+ }
197
+
198
+ if (!flags.greetedAt) {
199
+ const sent = await runInChat(chatScope(ownerChat), () => require("./io").send(greetingMessage()));
200
+ if (sent?.ok) writeFlags({ greetedAt: Date.now() });
201
+ }
202
+ armAuthWatcher(ownerChat);
203
+ }
204
+
205
+ module.exports = {
206
+ initBootOnboarding,
207
+ startInterview,
208
+ resolveOwnerChat,
209
+ readFlags,
210
+ FLAGS_FILE,
211
+ stopAuthWatcher,
212
+ };
package/core/identity.js CHANGED
@@ -47,6 +47,9 @@ function isConfiguredOwnerChannel(transport, channelId) {
47
47
  if (t === "telegram") return (cfg.CHAT_IDS || []).map(String).includes(c);
48
48
  if (t === "kazee") return !!cfg.config.KAZEE_OWNER_USER_ID && c === String(cfg.config.KAZEE_OWNER_USER_ID);
49
49
  if (t === "voice") return c === String(cfg.config.VOICE_OWNER_USER_ID || "voice-owner");
50
+ // Web dashboard chat: fixed single-owner channel gated by the dashboard
51
+ // session, so its identity is operator-implied rather than env-declared.
52
+ if (t === "web") return c === "web-owner";
50
53
  return false;
51
54
  }
52
55
 
package/core/router.js CHANGED
@@ -37,6 +37,18 @@ async function deliverRouterError(error) {
37
37
  const code = error?.code || "ROUTER_ERROR";
38
38
  if (!USER_VISIBLE_PROVIDER_ERRORS.has(code)) return null;
39
39
  const message = error?.message || "No compatible provider is available.";
40
+ if (code === "PROVIDER_UNAUTHENTICATED") {
41
+ const providerId = error?.details?.providerId;
42
+ await send([
43
+ "I'm not signed in to an AI provider yet, so I can't work on that message.",
44
+ "",
45
+ "Send /login to pick a model and connect — I'll walk you through it right here in chat.",
46
+ providerId === "codex"
47
+ ? "Shortcuts: /codex_login (device auth) or /codex_setup_token (paste an OpenAI API key securely)."
48
+ : "Shortcuts: /setup_token then /use_oauth_token (long-lived Claude token), or /login for the guided flow.",
49
+ ].join("\n"));
50
+ return { ok: false, status: "failed", error: { code, message } };
51
+ }
40
52
  await send(`${message}\n\nOpen /backend to review providers, or run /doctor for local compatibility and authentication checks.`);
41
53
  return { ok: false, status: "failed", error: { code, message } };
42
54
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inetafrica/open-claudia",
3
- "version": "3.0.10",
3
+ "version": "3.0.13",
4
4
  "description": "An always-on, provider-agnostic coding-agent harness for Claude Code and OpenAI Codex via chat",
5
5
  "main": "bot.js",
6
6
  "bin": {
@@ -32,11 +32,12 @@ assert.doesNotMatch(setup, /Your Claude Code bot is connected|Description=Claude
32
32
 
33
33
  const pkg = JSON.parse(read("package.json"));
34
34
  assert.match(pkg.description, /provider-agnostic coding-agent harness/i);
35
- // 3.0.10 approved by Sumeet 2026-07-13 ("Do the whole stack from end go
36
- // finish now then deploy it all out for me"): Kazee-only pods for the
37
- // agent-space wizard channel-aware required env keys in health.js, and
38
- // kazee owner bootstrap writes KAZEE_OWNER_USER_ID, never TELEGRAM_CHAT_ID.
39
- assert.strictEqual(pkg.version, "3.0.10", "release version must remain unchanged without explicit approval");
35
+ // 3.0.13 approved by Sumeet 2026-07-13 ("ok please proceed... once done
36
+ // please push it out" on the agent-space P0–P3 plan): web chat channel
37
+ // in-process WebAdapter, /api/chat SSE + send/action/media routes, Chat tab
38
+ // in the dashboard, and a control-token-only /api/otp/mint for AgentSpace
39
+ // magic-link SSO.
40
+ assert.strictEqual(pkg.version, "3.0.13", "release version must remain unchanged without explicit approval");
40
41
  for (const keyword of ["claude", "codex", "coding-agent", "provider-agnostic"]) {
41
42
  assert.ok(pkg.keywords.includes(keyword), `package keyword missing: ${keyword}`);
42
43
  }
package/web.js CHANGED
@@ -143,6 +143,59 @@ function checkAuth(req) {
143
143
  return webSessions.valid(token);
144
144
  }
145
145
 
146
+ // Hash both sides so timingSafeEqual gets equal-length buffers and the
147
+ // comparison leaks neither content nor password length.
148
+ function safeEqual(a, b) {
149
+ const ha = crypto.createHash("sha256").update(String(a)).digest();
150
+ const hb = crypto.createHash("sha256").update(String(b)).digest();
151
+ return crypto.timingSafeEqual(ha, hb);
152
+ }
153
+
154
+ // Login rate limiting: 5 failures → lockout with exponential backoff
155
+ // (30s, 60s, ... capped at 15min). Keyed by client IP (first XFF hop
156
+ // when behind the ingress). In-memory — resets on restart, which is fine.
157
+ const loginFailures = new Map();
158
+ const LOCKOUT_THRESHOLD = 5;
159
+ const LOCKOUT_BASE_MS = 30 * 1000;
160
+ const LOCKOUT_MAX_MS = 15 * 60 * 1000;
161
+
162
+ function clientKey(req) {
163
+ const xff = String(req.headers["x-forwarded-for"] || "").split(",")[0].trim();
164
+ return xff || req.socket?.remoteAddress || "unknown";
165
+ }
166
+
167
+ function loginLockedMs(req) {
168
+ const rec = loginFailures.get(clientKey(req));
169
+ if (!rec || !rec.lockedUntil) return 0;
170
+ const left = rec.lockedUntil - Date.now();
171
+ return left > 0 ? left : 0;
172
+ }
173
+
174
+ function recordLoginFailure(req) {
175
+ const key = clientKey(req);
176
+ const rec = loginFailures.get(key) || { count: 0, lockouts: 0, lockedUntil: 0 };
177
+ rec.count += 1;
178
+ if (rec.count >= LOCKOUT_THRESHOLD) {
179
+ rec.count = 0;
180
+ const wait = Math.min(LOCKOUT_BASE_MS * 2 ** rec.lockouts, LOCKOUT_MAX_MS);
181
+ rec.lockouts += 1;
182
+ rec.lockedUntil = Date.now() + wait;
183
+ }
184
+ loginFailures.set(key, rec);
185
+ }
186
+
187
+ function clearLoginFailures(req) {
188
+ loginFailures.delete(clientKey(req));
189
+ }
190
+
191
+ // Secure flag only when the client actually arrived over HTTPS (ingress
192
+ // sets X-Forwarded-Proto) — keeps plain-HTTP port-forward logins working.
193
+ function sessionCookie(req) {
194
+ const proto = String(req.headers["x-forwarded-proto"] || "").split(",")[0].trim();
195
+ const secure = proto === "https" ? "; Secure" : "";
196
+ return `oc_session=${webSessions.issue()}; Path=/; HttpOnly; SameSite=Strict; Max-Age=86400${secure}`;
197
+ }
198
+
146
199
  // ── Config helpers ─────────────────────────────────────────────────
147
200
 
148
201
  function loadEnv() {
@@ -289,6 +342,32 @@ function telegramGet(token, method) {
289
342
  });
290
343
  }
291
344
 
345
+ // Live channel list from the in-process adapter registry — the source of
346
+ // truth for what this pod is actually connected to (provisioning-time env
347
+ // goes stale once channels are attached/removed at runtime).
348
+ let tgUsernameCache = null;
349
+ async function liveChannels() {
350
+ let adapters = [];
351
+ try { adapters = require("./core/adapter-registry").getAdapters(); } catch (e) { return []; }
352
+ const out = [];
353
+ for (const a of adapters) {
354
+ const ch = { id: a.id, type: a.type };
355
+ if (a.type === "telegram") {
356
+ const token = loadEnv().TELEGRAM_BOT_TOKEN || process.env.TELEGRAM_BOT_TOKEN;
357
+ if (token) {
358
+ if (!tgUsernameCache) {
359
+ const me = await telegramGet(token, "getMe");
360
+ if (me && me.ok) tgUsernameCache = (me.result && me.result.username) || null;
361
+ }
362
+ if (tgUsernameCache) ch.username = tgUsernameCache;
363
+ }
364
+ }
365
+ if (a.type === "kazee" && a.botUserId) ch.botUserId = String(a.botUserId);
366
+ out.push(ch);
367
+ }
368
+ return out;
369
+ }
370
+
292
371
  // ── API routes ─────────────────────────────────────────────────────
293
372
 
294
373
  async function handleAPI(req, res, body, dependencies = {}) {
@@ -296,14 +375,23 @@ async function handleAPI(req, res, body, dependencies = {}) {
296
375
 
297
376
  // Login — no auth required
298
377
  if (url === "/api/login" && req.method === "POST") {
299
- const { password } = JSON.parse(body);
300
- if (password === getPassword()) {
378
+ const lockedMs = loginLockedMs(req);
379
+ if (lockedMs > 0) {
380
+ const seconds = Math.ceil(lockedMs / 1000);
381
+ res.writeHead(429, { "Content-Type": "application/json", "Retry-After": String(seconds) });
382
+ return res.end(JSON.stringify({ ok: false, error: `Too many attempts — try again in ${seconds}s` }));
383
+ }
384
+ let password;
385
+ try { ({ password } = JSON.parse(body)); } catch (e) { /* fall through to failure */ }
386
+ if (typeof password === "string" && safeEqual(password, getPassword())) {
387
+ clearLoginFailures(req);
301
388
  res.writeHead(200, {
302
389
  "Content-Type": "application/json",
303
- "Set-Cookie": `oc_session=${webSessions.issue()}; Path=/; HttpOnly; SameSite=Strict; Max-Age=86400`,
390
+ "Set-Cookie": sessionCookie(req),
304
391
  });
305
- return res.end(JSON.stringify({ ok: true, mustChangePassword: !isPasswordChanged() }));
392
+ return res.end(JSON.stringify({ ok: true }));
306
393
  }
394
+ recordLoginFailure(req);
307
395
  res.writeHead(401, { "Content-Type": "application/json" });
308
396
  return res.end(JSON.stringify({ ok: false, error: "Wrong password" }));
309
397
  }
@@ -347,6 +435,7 @@ async function handleAPI(req, res, body, dependencies = {}) {
347
435
  providers: providerStatus.providers,
348
436
  defaultProvider: providerStatus.defaultProvider,
349
437
  onboarded: env.ONBOARDED === "true",
438
+ channels: await liveChannels(),
350
439
  }));
351
440
  }
352
441
 
@@ -575,10 +664,97 @@ async function handleAPI(req, res, body, dependencies = {}) {
575
664
  }));
576
665
  }
577
666
 
578
- // Change password
667
+ // ── Chat (dashboard ↔ bot, via the in-process web channel adapter) ──
668
+ if (url.startsWith("/api/chat/")) {
669
+ let webChat = null;
670
+ try { webChat = require("./core/adapter-registry").findAdapter("web"); } catch (e) {}
671
+ if (!webChat) {
672
+ res.writeHead(503, { "Content-Type": "application/json" });
673
+ return res.end(JSON.stringify({ error: "Chat unavailable — bot not running in this process" }));
674
+ }
675
+
676
+ // Live event stream. Replays history on connect, then pushes frames as
677
+ // the bot replies (message / edit / delete / typing / file / voice).
678
+ if (url.startsWith("/api/chat/stream") && req.method === "GET") {
679
+ res.writeHead(200, {
680
+ "Content-Type": "text/event-stream",
681
+ "Cache-Control": "no-cache",
682
+ "Connection": "keep-alive",
683
+ "X-Accel-Buffering": "no",
684
+ });
685
+ res.write(`data: ${JSON.stringify({ kind: "history", frames: webChat.history() })}\n\n`);
686
+ const unsubscribe = webChat.subscribe((frame) => {
687
+ try { res.write(`data: ${JSON.stringify(frame)}\n\n`); } catch (e) {}
688
+ });
689
+ const heartbeat = setInterval(() => { try { res.write(": ping\n\n"); } catch (e) {} }, 25000);
690
+ // res "close" = connection torn down. (req "close" fires as soon as the
691
+ // GET body completes in modern Node, which would unsubscribe instantly.)
692
+ res.on("close", () => { clearInterval(heartbeat); unsubscribe(); });
693
+ return;
694
+ }
695
+
696
+ if (url === "/api/chat/send" && req.method === "POST") {
697
+ let text = "";
698
+ try { ({ text } = JSON.parse(body || "{}")); } catch (e) {}
699
+ if (!text || !String(text).trim()) {
700
+ res.writeHead(400, { "Content-Type": "application/json" });
701
+ return res.end(JSON.stringify({ error: "Empty message" }));
702
+ }
703
+ const messageId = webChat.postText(String(text));
704
+ res.writeHead(202, { "Content-Type": "application/json" });
705
+ return res.end(JSON.stringify({ ok: true, messageId }));
706
+ }
707
+
708
+ if (url === "/api/chat/action" && req.method === "POST") {
709
+ let parsed = {};
710
+ try { parsed = JSON.parse(body || "{}"); } catch (e) {}
711
+ const callbackId = webChat.postAction(parsed);
712
+ if (!callbackId) {
713
+ res.writeHead(400, { "Content-Type": "application/json" });
714
+ return res.end(JSON.stringify({ error: "Missing action payload" }));
715
+ }
716
+ res.writeHead(202, { "Content-Type": "application/json" });
717
+ return res.end(JSON.stringify({ ok: true, callbackId }));
718
+ }
719
+
720
+ if (url.startsWith("/api/chat/media/") && req.method === "GET") {
721
+ const id = url.slice("/api/chat/media/".length).split("?")[0];
722
+ const entry = webChat.getMedia(id);
723
+ if (!entry) {
724
+ res.writeHead(404, { "Content-Type": "application/json" });
725
+ return res.end(JSON.stringify({ error: "Expired or unknown media" }));
726
+ }
727
+ const stat = fs.statSync(entry.path);
728
+ res.writeHead(200, {
729
+ "Content-Type": entry.mime || "application/octet-stream",
730
+ "Content-Length": stat.size,
731
+ "Content-Disposition": `inline; filename="${(entry.fileName || "file").replace(/"/g, "")}"`,
732
+ });
733
+ return fs.createReadStream(entry.path).pipe(res);
734
+ }
735
+
736
+ res.writeHead(404, { "Content-Type": "application/json" });
737
+ return res.end(JSON.stringify({ error: "Unknown chat route" }));
738
+ }
739
+
740
+ // Platform SSO: mint a one-time magic-link token. Control-token only —
741
+ // this is a machine-to-machine surface for AgentSpace, never exposed to
742
+ // dashboard sessions (they are already logged in and gain nothing).
743
+ if (url === "/api/otp/mint" && req.method === "POST") {
744
+ if (!checkBearerAuth(req)) {
745
+ res.writeHead(403, { "Content-Type": "application/json" });
746
+ return res.end(JSON.stringify({ error: "Control token required" }));
747
+ }
748
+ const { mint } = require("./core/web-otp");
749
+ const { token, expiresAt } = mint();
750
+ res.writeHead(200, { "Content-Type": "application/json" });
751
+ return res.end(JSON.stringify({ ok: true, token, path: `/otp/${token}`, expiresAt }));
752
+ }
753
+
754
+ // Change password (voluntary — never forced)
579
755
  if (url === "/api/password" && req.method === "POST") {
580
756
  const { current, newPassword } = JSON.parse(body);
581
- if (current !== getPassword()) {
757
+ if (typeof current !== "string" || !safeEqual(current, getPassword())) {
582
758
  res.writeHead(400, { "Content-Type": "application/json" });
583
759
  return res.end(JSON.stringify({ error: "Wrong current password" }));
584
760
  }
@@ -593,7 +769,7 @@ async function handleAPI(req, res, body, dependencies = {}) {
593
769
  webSessions.revokeAll();
594
770
  res.writeHead(200, {
595
771
  "Content-Type": "application/json",
596
- "Set-Cookie": `oc_session=${webSessions.issue()}; Path=/; HttpOnly; SameSite=Strict; Max-Age=86400`,
772
+ "Set-Cookie": sessionCookie(req),
597
773
  });
598
774
  return res.end(JSON.stringify({ ok: true }));
599
775
  }
@@ -675,6 +851,19 @@ function getHTML() {
675
851
  table.usage-table th, table.usage-table td { text-align: left; padding: 8px 6px; border-bottom: 1px solid #222; white-space: nowrap; }
676
852
  table.usage-table th { color: #888; font-weight: 500; }
677
853
  table.usage-table td:first-child { color: #e0e0e0; }
854
+ .chat-box { display: flex; flex-direction: column; height: calc(100vh - 210px); min-height: 320px; }
855
+ .chat-log { flex: 1; overflow-y: auto; padding: 4px 2px; display: flex; flex-direction: column; gap: 10px; }
856
+ .chat-bubble { max-width: 85%; padding: 10px 14px; border-radius: 14px; font-size: 14px; line-height: 1.45; white-space: pre-wrap; word-break: break-word; }
857
+ .chat-bubble.user { align-self: flex-end; background: #6366f1; color: #fff; border-bottom-right-radius: 4px; }
858
+ .chat-bubble.bot { align-self: flex-start; background: #1a1a1a; border: 1px solid #2a2a2a; border-bottom-left-radius: 4px; }
859
+ .chat-bubble .chat-btns { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; }
860
+ .chat-bubble .chat-btns button { padding: 6px 12px; font-size: 13px; background: #333; }
861
+ .chat-bubble .chat-btns button:hover { background: #444; }
862
+ .chat-bubble a { color: #a5b4fc; }
863
+ .chat-typing { align-self: flex-start; color: #888; font-size: 13px; padding: 4px 8px; }
864
+ .chat-input-row { display: flex; gap: 8px; margin-top: 12px; }
865
+ .chat-input-row textarea { min-height: 44px; max-height: 140px; resize: none; flex: 1; }
866
+ .chat-input-row button { align-self: flex-end; }
678
867
  </style>
679
868
  <script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
680
869
  </head>
@@ -718,43 +907,14 @@ function showLogin() {
718
907
  async function doLogin() {
719
908
  const pw = $("#pw").value;
720
909
  const r = await api("/api/login", { method: "POST", body: { password: pw } });
721
- if (r?.ok && r.mustChangePassword) showForceChangePassword(pw);
722
- else if (r?.ok) init();
723
- else $("#login-msg").innerHTML = '<div class="msg err">Wrong password</div>';
724
- }
725
-
726
- function showForceChangePassword(currentPw) {
727
- $("#app").innerHTML = \`
728
- <div class="login-page"><div class="login-box">
729
- <h1>Change Password</h1>
730
- <p class="subtitle">You must set a secure password before continuing</p>
731
- <div id="change-msg"></div>
732
- <div class="card">
733
- <div class="form-group">
734
- <input type="password" id="new-pw-1" placeholder="New password">
735
- </div>
736
- <div class="form-group">
737
- <input type="password" id="new-pw-2" placeholder="Confirm password" onkeydown="if(event.key==='Enter')doForceChange()">
738
- </div>
739
- <p style="font-size:11px;color:#888;margin:8px 0">Min 12 chars, uppercase, lowercase, number, and symbol</p>
740
- <button onclick="doForceChange()" style="width:100%">Set Password</button>
741
- </div>
742
- </div></div>\`;
743
- window._forceChangePw = currentPw;
744
- setTimeout(() => $("#new-pw-1")?.focus(), 100);
745
- }
746
-
747
- async function doForceChange() {
748
- const p1 = $("#new-pw-1").value, p2 = $("#new-pw-2").value;
749
- if (p1 !== p2) { $("#change-msg").innerHTML = '<div class="msg err">Passwords do not match</div>'; return; }
750
- const r = await api("/api/password", { method: "POST", body: { current: window._forceChangePw, newPassword: p1 } });
751
- if (r?.ok) { delete window._forceChangePw; init(); }
752
- else $("#change-msg").innerHTML = '<div class="msg err">' + (r?.error || "Failed") + '</div>';
910
+ if (r?.ok) init();
911
+ else $("#login-msg").innerHTML = '<div class="msg err">' + (r?.error || "Wrong password") + '</div>';
753
912
  }
754
913
 
755
914
  function render() {
756
915
  const tabs = [
757
916
  { id: "dashboard", label: "Dashboard" },
917
+ { id: "chat", label: "Chat" },
758
918
  { id: "usage", label: "Usage" },
759
919
  { id: "auth", label: "Users" },
760
920
  { id: "credentials", label: "Credentials" },
@@ -780,10 +940,116 @@ function render() {
780
940
  loadTab();
781
941
  }
782
942
 
783
- function switchTab(tab) { currentTab = tab; render(); }
943
+ function switchTab(tab) {
944
+ if (tab !== "chat" && window.chatES) { try { window.chatES.close(); } catch (e) {} window.chatES = null; }
945
+ currentTab = tab;
946
+ render();
947
+ }
948
+
949
+ // ── Chat tab ──
950
+ function chatEsc(s) {
951
+ return String(s == null ? "" : s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
952
+ }
953
+
954
+ function chatFrameHTML(f) {
955
+ if (f.kind === "message") {
956
+ const cls = f.role === "user" ? "user" : "bot";
957
+ let inner = chatEsc(f.text);
958
+ if (f.buttons && f.buttons.length) {
959
+ inner += '<div class="chat-btns">' + f.buttons.map(function (b) {
960
+ return '<button onclick="chatAction(\\'' + chatEsc(b.id) + '\\',\\'' + chatEsc(b.payload || b.id) + '\\',\\'' + chatEsc(f.messageId) + '\\')">' + chatEsc(b.label) + '</button>';
961
+ }).join("") + '</div>';
962
+ }
963
+ return '<div class="chat-bubble ' + cls + '" data-mid="' + chatEsc(f.messageId) + '">' + inner + '</div>';
964
+ }
965
+ if (f.kind === "file") {
966
+ return '<div class="chat-bubble bot" data-mid="' + chatEsc(f.messageId) + '">📎 <a href="' + chatEsc(f.url) + '" target="_blank" rel="noopener">' + chatEsc(f.fileName || "file") + '</a>' + (f.caption ? '<br>' + chatEsc(f.caption) : '') + '</div>';
967
+ }
968
+ if (f.kind === "voice") {
969
+ return '<div class="chat-bubble bot" data-mid="' + chatEsc(f.messageId) + '"><audio controls src="' + chatEsc(f.url) + '"></audio></div>';
970
+ }
971
+ return "";
972
+ }
973
+
974
+ function chatApplyFrame(f) {
975
+ const log = $("#chat-log");
976
+ if (!log) return;
977
+ const typingEl = $("#chat-typing");
978
+ if (f.kind === "typing") {
979
+ if (typingEl) typingEl.classList.remove("hidden");
980
+ clearTimeout(window.chatTypingTimer);
981
+ window.chatTypingTimer = setTimeout(function () { if ($("#chat-typing")) $("#chat-typing").classList.add("hidden"); }, 8000);
982
+ return;
983
+ }
984
+ if (typingEl && (f.kind === "message" || f.kind === "edit")) typingEl.classList.add("hidden");
985
+ if (f.kind === "edit" || f.kind === "delete") {
986
+ const target = log.querySelector('[data-mid="' + (window.CSS && CSS.escape ? CSS.escape(f.messageId) : f.messageId) + '"]');
987
+ if (f.kind === "delete") { if (target) target.remove(); return; }
988
+ if (target) {
989
+ target.outerHTML = chatFrameHTML({ kind: "message", role: "assistant", messageId: f.messageId, text: f.text, buttons: f.buttons });
990
+ }
991
+ log.scrollTop = log.scrollHeight;
992
+ return;
993
+ }
994
+ const html = chatFrameHTML(f);
995
+ if (!html) return;
996
+ typingEl.insertAdjacentHTML("beforebegin", html);
997
+ log.scrollTop = log.scrollHeight;
998
+ }
999
+
1000
+ function chatConnect() {
1001
+ if (window.chatES) { try { window.chatES.close(); } catch (e) {} }
1002
+ const es = new EventSource("/api/chat/stream");
1003
+ window.chatES = es;
1004
+ es.onmessage = function (ev) {
1005
+ let f;
1006
+ try { f = JSON.parse(ev.data); } catch (e) { return; }
1007
+ if (f.kind === "history") {
1008
+ const log = $("#chat-log");
1009
+ if (!log) return;
1010
+ log.innerHTML = f.frames.map(chatFrameHTML).join("") + '<div id="chat-typing" class="chat-typing hidden">thinking…</div>';
1011
+ log.scrollTop = log.scrollHeight;
1012
+ return;
1013
+ }
1014
+ chatApplyFrame(f);
1015
+ };
1016
+ es.onerror = function () {
1017
+ // EventSource auto-reconnects; a 401 (expired session) won't, so bounce
1018
+ // through the login screen on persistent failure.
1019
+ setTimeout(function () { if (window.chatES === es && es.readyState === EventSource.CLOSED) api("/api/status"); }, 3000);
1020
+ };
1021
+ }
1022
+
1023
+ async function chatSend() {
1024
+ const input = $("#chat-input");
1025
+ const text = input.value.trim();
1026
+ if (!text) return;
1027
+ input.value = "";
1028
+ input.style.height = "";
1029
+ await api("/api/chat/send", { method: "POST", body: { text: text } });
1030
+ }
1031
+
1032
+ async function chatAction(id, payload, messageId) {
1033
+ await api("/api/chat/action", { method: "POST", body: { id: id, payload: payload, messageId: messageId } });
1034
+ }
784
1035
 
785
1036
  async function loadTab() {
786
1037
  const el = $("#content");
1038
+ if (currentTab === "chat") {
1039
+ el.innerHTML =
1040
+ '<div class="chat-box">' +
1041
+ '<div id="chat-log" class="chat-log"><div id="chat-typing" class="chat-typing hidden">thinking…</div></div>' +
1042
+ '<div class="chat-input-row">' +
1043
+ '<textarea id="chat-input" placeholder="Message ${botName}…" rows="1"' +
1044
+ ' onkeydown="if(event.key===\\'Enter\\'&&!event.shiftKey){event.preventDefault();chatSend();}"' +
1045
+ ' oninput="this.style.height=\\'\\';this.style.height=Math.min(this.scrollHeight,140)+\\'px\\';"></textarea>' +
1046
+ '<button onclick="chatSend()">Send</button>' +
1047
+ '</div>' +
1048
+ '</div>';
1049
+ chatConnect();
1050
+ setTimeout(function () { $("#chat-input") && $("#chat-input").focus(); }, 100);
1051
+ return;
1052
+ }
787
1053
  if (currentTab === "dashboard") {
788
1054
  el.innerHTML = \`
789
1055
  <div class="card">
@@ -1248,7 +1514,7 @@ function startWebServer(options = {}) {
1248
1514
  if (consume(token)) {
1249
1515
  res.writeHead(302, {
1250
1516
  "Location": "/",
1251
- "Set-Cookie": `oc_session=${webSessions.issue()}; Path=/; HttpOnly; SameSite=Strict; Max-Age=86400`,
1517
+ "Set-Cookie": sessionCookie(req),
1252
1518
  });
1253
1519
  return res.end();
1254
1520
  }