alvin-bot 4.4.7 → 4.5.0

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
@@ -2,6 +2,74 @@
2
2
 
3
3
  All notable changes to Alvin Bot are documented here.
4
4
 
5
+ ## [4.5.0] — 2026-04-09
6
+
7
+ ### 🐛 TUI Bug Fixes (critical — the old TUI was effectively unusable)
8
+
9
+ **Double-character echo fixed** — Every keystroke in `alvin-bot tui` appeared twice (typing `hello` showed as `hheelllloo`). Root cause: `src/tui/index.ts` called `process.stdin.setRawMode(false)` alongside `readline.createInterface({ terminal: true })`. readline with `terminal: true` already controls the tty mode for its own line editor; forcing cooked mode on top of that makes both the terminal AND readline echo every keystroke. Removed the explicit `setRawMode(false)` call and let readline manage the tty state itself.
10
+
11
+ **Cursor chaos fixed** — The old `redrawHeader()` function wrote `\x1b7` / `\x1b8` (save/restore cursor) escape sequences that raced with readline's internal cursor tracking, producing garbled output mid-stream. The header redraw is now a no-op during active streaming and uses readline's own `cursorTo`/`clearLine` helpers otherwise — cooperating with readline instead of fighting it.
12
+
13
+ **Prompt state machine consolidated** — `showPrompt()` was called at ~7 different places, each re-rendering the prompt at potentially racy moments. It is now the single source of truth and no-ops during streaming. Every helper (`printUser`, `printAssistantStart`, `printInfo`, `printError`, `printSuccess`, `printTool`) calls `clearCurrentLine()` first, so the input line is always cleanly wiped before output is written above it.
14
+
15
+ **Terminal resize handling** — Added `process.stdout.on("resize", …)` so the header redraws correctly when the user resizes the window (when safe).
16
+
17
+ ### ✨ New Feature: Parallel Observation + Session Routing
18
+
19
+ **The big one.** Before 4.5.0, the TUI/Web-UI shared the exact same session as the Telegram bot (both keyed to `config.allowedUsers[0]`). That meant `/new` in the TUI wiped the Telegram history, and the TUI had no visibility into live Telegram activity. This release cleanly separates the two and adds live mirroring in both directions.
20
+
21
+ #### New in-process broadcast bus — `src/services/broadcast.ts`
22
+
23
+ A tiny typed `EventEmitter` with four event types:
24
+ - `user_msg` — a user sent a message on a platform (Telegram, WhatsApp, etc.)
25
+ - `response_start` — the bot started generating a response
26
+ - `response_delta` — a streaming text chunk
27
+ - `response_done` — the response is complete
28
+
29
+ Fire-and-forget, zero backpressure, no history retention. The Telegram handler (`src/handlers/message.ts`) emits these events around its normal processing. The web server subscribes once at module load and fan-outs to every connected WebSocket client as `mirror:*` messages. Platform-agnostic signature so WhatsApp/Signal can plug in later without architectural changes.
30
+
31
+ #### TUI session isolation
32
+
33
+ The TUI now owns its own session key, completely separate from the Telegram user's session:
34
+
35
+ - **Default**: `alvin-bot tui` → fresh ephemeral session `tui:ephemeral:<timestamp>`. Every TUI start is a clean slate.
36
+ - **Persistent**: `alvin-bot tui --resume` → resumes `tui:local`, a long-lived session that survives TUI restarts.
37
+
38
+ Your Telegram conversation and your TUI conversation no longer overwrite each other's history. `/new` in the TUI only resets the TUI session.
39
+
40
+ #### `/target tui|telegram` — remote-control the Telegram session from TUI
41
+
42
+ New TUI command to switch where your typed messages go:
43
+
44
+ - **`/target tui`** (default) — Your messages go into the TUI's isolated session. Responses are rendered in the TUI only.
45
+ - **`/target telegram`** — Your messages enter the Telegram session (shared memory with whoever messages your bot on Telegram). The bot responds **both** in the TUI (via the open WebSocket) **and** in the actual Telegram chat (via the existing delivery queue). The active target is shown in the header as `→ Telegram` or `TUI session`.
46
+
47
+ Note: Telegram bot API does not allow bots to forge user messages, so your original prompt stays in the TUI — only the bot's response lands in Telegram. This is the closest possible equivalent to "remote typing into Telegram".
48
+
49
+ #### `/observe on|off` — mirror Telegram activity into the TUI
50
+
51
+ When observer mode is on (default), every Telegram user message and streaming bot response is mirrored into the TUI with distinct dim + `📱 Tel` styling. You can watch a live conversation happen from the TUI while running your own independent session in parallel. Toggle off with `/observe off` if the mirror noise gets in the way.
52
+
53
+ ### 🧠 Architecture / Design Note
54
+
55
+ This feature deliberately does **not** go through the Claude Agent SDK or touch the `pathToClaudeCodeExecutable` flow. The broadcast bus is a pure observation layer in alvin-bot's own process, and session routing is just a different `sessionKey` lookup in the existing `getSession()` map. The bot's 1st-party auth behavior (CLI-backed session routing) is preserved exactly as before.
56
+
57
+ ### 📦 Compatibility
58
+
59
+ This is a minor release (new feature), not a patch. No breaking changes to existing commands, existing behavior, or existing API endpoints. Old clients that don't send a `target` field continue to work exactly as before (falling back to the primary Telegram user's session).
60
+
61
+ ```bash
62
+ npm update -g alvin-bot
63
+ alvin-bot tui # fresh TUI session, observer on by default
64
+ alvin-bot tui --resume # resume persistent tui:local session
65
+ ```
66
+
67
+ Once inside TUI:
68
+ - `/target telegram` — route your messages into the Telegram session (responses land in both TUI and Telegram chat)
69
+ - `/target tui` — switch back to isolated TUI session (default)
70
+ - `/observe off` — stop mirroring Telegram activity
71
+ - `/observe on` — resume mirroring
72
+
5
73
  ## [4.4.7] — 2026-04-09
6
74
 
7
75
  ### 🔐 Security / Dependencies
@@ -12,6 +12,7 @@ import { trackAndAdapt } from "../services/language-detect.js";
12
12
  import { shouldCompact, compactSession } from "../services/compaction.js";
13
13
  import { emit } from "../services/hooks.js";
14
14
  import { trackUsage } from "../services/usage-tracker.js";
15
+ import { emitUserMessage as broadcastUserMessage, emitResponseStart as broadcastResponseStart, emitResponseDelta as broadcastResponseDelta, emitResponseDone as broadcastResponseDone, } from "../services/broadcast.js";
15
16
  /** React to a message with an emoji. Silently fails if reactions aren't supported. */
16
17
  async function react(ctx, emoji) {
17
18
  try {
@@ -85,6 +86,22 @@ export async function handleMessage(ctx) {
85
86
  await ctx.api.sendChatAction(ctx.chat.id, "typing");
86
87
  session.messageCount++;
87
88
  emit("message:received", { userId, text, platform: "telegram" });
89
+ // v4.5.0: broadcast the user message so TUI/WebUI observers can mirror it.
90
+ // The broadcast bus is fire-and-forget — never affects the Telegram flow.
91
+ broadcastUserMessage({
92
+ platform: "telegram",
93
+ userId,
94
+ userName: ctx.from?.first_name || ctx.from?.username,
95
+ chatId: ctx.chat.id,
96
+ text,
97
+ ts: Date.now(),
98
+ });
99
+ broadcastResponseStart({
100
+ platform: "telegram",
101
+ userId,
102
+ chatId: ctx.chat.id,
103
+ ts: Date.now(),
104
+ });
88
105
  // Determine provider type early for compaction check
89
106
  const registry = getRegistry();
90
107
  const activeProvider = registry.getActive();
@@ -130,11 +147,25 @@ export async function handleMessage(ctx) {
130
147
  addToHistory(userId, { role: "user", content: text });
131
148
  }
132
149
  // Stream response from provider (with fallback)
150
+ let lastBroadcastLen = 0;
133
151
  for await (const chunk of registry.queryWithFallback(queryOpts)) {
134
152
  switch (chunk.type) {
135
153
  case "text":
136
154
  finalText = chunk.text || "";
137
155
  await streamer.update(finalText);
156
+ // Emit the new delta for observers — accumulated text minus what
157
+ // we already broadcast.
158
+ if (finalText.length > lastBroadcastLen) {
159
+ const delta = finalText.slice(lastBroadcastLen);
160
+ broadcastResponseDelta({
161
+ platform: "telegram",
162
+ userId,
163
+ chatId: ctx.chat.id,
164
+ delta,
165
+ ts: Date.now(),
166
+ });
167
+ lastBroadcastLen = finalText.length;
168
+ }
138
169
  break;
139
170
  case "tool_use":
140
171
  // Could show tool activity indicator
@@ -161,6 +192,15 @@ export async function handleMessage(ctx) {
161
192
  }
162
193
  await streamer.finalize(finalText);
163
194
  emit("message:sent", { userId, text: finalText, platform: "telegram" });
195
+ // v4.5.0: tell observers the response is complete.
196
+ broadcastResponseDone({
197
+ platform: "telegram",
198
+ userId,
199
+ chatId: ctx.chat.id,
200
+ finalText,
201
+ cost: session.costByProvider[registry.getActiveKey()],
202
+ ts: Date.now(),
203
+ });
164
204
  // Clear thinking reaction (replace with nothing — message was answered)
165
205
  await react(ctx, "👍");
166
206
  // Add assistant response to history (for non-SDK providers)
@@ -0,0 +1,52 @@
1
+ /**
2
+ * In-process Broadcast Bus — Telegram Activity Mirror (v4.5.0+)
3
+ *
4
+ * A tiny typed EventEmitter that lets the Telegram handler announce every
5
+ * user message, every streaming response delta, and every "response done"
6
+ * event. The web server subscribes to the same bus and forwards each event
7
+ * to all connected WebSocket clients as `mirror:*` messages.
8
+ *
9
+ * The TUI (and Web UI) can then show the full Telegram conversation in
10
+ * real time, side-by-side with its own isolated chat session.
11
+ *
12
+ * Design constraints:
13
+ * - Zero backpressure: events are fire-and-forget, listeners must be fast
14
+ * - No memory retention: no history is buffered here, just live pub/sub
15
+ * - Platform-agnostic signature so we can later mirror WhatsApp/Signal too
16
+ * - Does not touch the Claude Agent SDK or any provider internals — this
17
+ * is a pure observation layer
18
+ */
19
+ import { EventEmitter } from "events";
20
+ class TypedBus extends EventEmitter {
21
+ emit(event, ...args) {
22
+ return super.emit(event, ...args);
23
+ }
24
+ on(event, listener) {
25
+ return super.on(event, listener);
26
+ }
27
+ off(event, listener) {
28
+ return super.off(event, listener);
29
+ }
30
+ }
31
+ /**
32
+ * Singleton bus. Import and call methods directly — no need for a factory.
33
+ *
34
+ * EventEmitter default maxListeners is 10; we bump it because a single
35
+ * web server connection may subscribe many listeners (one per connected
36
+ * WS client on a busy day).
37
+ */
38
+ export const broadcast = new TypedBus();
39
+ broadcast.setMaxListeners(100);
40
+ // ── Convenience Emitters ───────────────────────────────────────────────────
41
+ export function emitUserMessage(payload) {
42
+ broadcast.emit("user_msg", payload);
43
+ }
44
+ export function emitResponseStart(payload) {
45
+ broadcast.emit("response_start", payload);
46
+ }
47
+ export function emitResponseDelta(payload) {
48
+ broadcast.emit("response_delta", payload);
49
+ }
50
+ export function emitResponseDone(payload) {
51
+ broadcast.emit("response_done", payload);
52
+ }
package/dist/tui/index.js CHANGED
@@ -16,7 +16,7 @@
16
16
  *
17
17
  * Usage: alvin-bot tui [--port 3100] [--host localhost] [--lang en|de]
18
18
  */
19
- import { createInterface } from "readline";
19
+ import { createInterface, cursorTo, clearLine as rlClearLine } from "readline";
20
20
  import WebSocket from "ws";
21
21
  import http from "http";
22
22
  import { initI18n, t } from "../i18n.js";
@@ -57,11 +57,17 @@ let connected = false;
57
57
  let currentModel = "loading...";
58
58
  let totalCost = 0;
59
59
  let isStreaming = false;
60
+ let isMirrorStreaming = false;
60
61
  let currentResponse = "";
61
62
  let currentToolName = "";
62
63
  let toolCount = 0;
63
64
  const inputHistory = [];
64
65
  let historyIndex = -1;
66
+ let activeTarget = "tui";
67
+ let observerEnabled = true;
68
+ // TUI's own session key — either ephemeral (new every start) or persistent
69
+ // ("tui:local") if --resume is passed. Set once in startTUI().
70
+ let tuiSessionKey = `tui:ephemeral:${Date.now()}`;
65
71
  const host = process.argv.includes("--host")
66
72
  ? process.argv[process.argv.indexOf("--host") + 1] || "localhost"
67
73
  : "localhost";
@@ -76,8 +82,14 @@ const HEADER_LINES = 3;
76
82
  function getWidth() {
77
83
  return process.stdout.columns || 80;
78
84
  }
79
- function clearLine() {
80
- process.stdout.write("\r\x1b[K");
85
+ /**
86
+ * Clear the current readline input line so we can write content "above" the
87
+ * prompt cleanly. Uses readline's own cursor API instead of raw escape
88
+ * sequences — this cooperates with readline's internal cursor tracking.
89
+ */
90
+ function clearCurrentLine() {
91
+ cursorTo(process.stdout, 0);
92
+ rlClearLine(process.stdout, 0);
81
93
  }
82
94
  function drawHeader() {
83
95
  const w = getWidth();
@@ -85,61 +97,78 @@ function drawHeader() {
85
97
  const status = connected ? t("tui.connected") : t("tui.disconnected");
86
98
  const modelStr = `${C.brightMagenta}${currentModel}${C.reset}`;
87
99
  const costStr = totalCost > 0 ? ` ${C.gray}· $${totalCost.toFixed(4)}${C.reset}` : "";
100
+ const targetStr = ` ${C.gray}│${C.reset} ${C.brightYellow}${activeTarget === "telegram" ? "→ Telegram" : "TUI session"}${C.reset}`;
88
101
  const title = `${C.bold}${C.brightCyan}${t("tui.title")}${C.reset}`;
89
- const right = `${statusDot} ${status} ${C.gray}│${C.reset} ${modelStr}${costStr}`;
102
+ const right = `${statusDot} ${status} ${C.gray}│${C.reset} ${modelStr}${costStr}${targetStr}`;
90
103
  console.log(`${C.gray}${"─".repeat(w)}${C.reset}`);
91
104
  console.log(` ${title}${"".padEnd(10)}${right}`);
92
105
  console.log(`${C.gray}${"─".repeat(w)}${C.reset}`);
93
106
  }
107
+ /**
108
+ * Redraw the header in place. Only safe to call when NOT streaming —
109
+ * the previous implementation used aggressive cursor save/restore escape
110
+ * sequences that collided with readline's internal cursor state and
111
+ * produced garbled output. Now this is a no-op during streaming and
112
+ * a clean redraw otherwise.
113
+ */
94
114
  function redrawHeader() {
95
- // Save cursor, move to top, redraw header, restore cursor
96
- process.stdout.write("\x1b7"); // Save cursor position
97
- process.stdout.write("\x1b[H"); // Move to top-left (1,1)
98
- // Clear the 3 header lines
115
+ if (isStreaming)
116
+ return; // Don't touch the cursor mid-stream
117
+ clearCurrentLine();
118
+ process.stdout.write("\x1b[s"); // Save cursor
119
+ process.stdout.write("\x1b[H"); // Move to top-left
99
120
  for (let i = 0; i < HEADER_LINES; i++) {
100
- process.stdout.write("\x1b[K"); // Clear line
121
+ cursorTo(process.stdout, 0);
122
+ rlClearLine(process.stdout, 0);
101
123
  if (i < HEADER_LINES - 1)
102
- process.stdout.write("\x1b[1B"); // Move down
124
+ process.stdout.write("\x1b[1B");
103
125
  }
104
- process.stdout.write("\x1b[H"); // Back to top
126
+ process.stdout.write("\x1b[H");
105
127
  drawHeader();
106
- process.stdout.write("\x1b8"); // Restore cursor position
128
+ process.stdout.write("\x1b[u"); // Restore cursor
129
+ if (rl && !isStreaming)
130
+ rl.prompt(true);
107
131
  }
108
132
  function drawHelp() {
109
133
  console.log(`
110
134
  ${C.bold}${t("help.title")}${C.reset}
111
- ${C.cyan}/model${C.reset} ${t("help.model")}
112
- ${C.cyan}/status${C.reset} ${t("help.status")}
113
- ${C.cyan}/clear${C.reset} ${t("help.clear")}
114
- ${C.cyan}/cron${C.reset} ${t("help.cron")}
115
- ${C.cyan}/doctor${C.reset} ${t("help.doctor")}
116
- ${C.cyan}/backup${C.reset} ${t("help.backup")}
117
- ${C.cyan}/restart${C.reset} ${t("help.restart")}
118
- ${C.cyan}/help${C.reset} ${t("help.help")}
119
- ${C.cyan}/quit${C.reset} ${t("help.quit")}
135
+ ${C.cyan}/model${C.reset} ${t("help.model")}
136
+ ${C.cyan}/status${C.reset} ${t("help.status")}
137
+ ${C.cyan}/clear${C.reset} ${t("help.clear")}
138
+ ${C.cyan}/cron${C.reset} ${t("help.cron")}
139
+ ${C.cyan}/doctor${C.reset} ${t("help.doctor")}
140
+ ${C.cyan}/backup${C.reset} ${t("help.backup")}
141
+ ${C.cyan}/restart${C.reset} ${t("help.restart")}
142
+ ${C.cyan}/target tui${C.reset}|${C.cyan}telegram${C.reset} Switch where your messages go
143
+ ${C.cyan}/observe on${C.reset}|${C.cyan}off${C.reset} Mirror Telegram activity (default: on)
144
+ ${C.cyan}/help${C.reset} ${t("help.help")}
145
+ ${C.cyan}/quit${C.reset} ${t("help.quit")}
120
146
 
121
147
  ${C.dim}${t("help.footer")}${C.reset}
122
148
  `);
123
149
  }
124
150
  function printUser(text) {
151
+ clearCurrentLine();
125
152
  console.log(`\n${C.bold}${C.brightGreen}${t("tui.you")}:${C.reset} ${text}`);
126
153
  }
127
154
  function printAssistantStart() {
128
- process.stdout.write(`\n${C.bold}${C.brightBlue}Alvin Bot:${C.reset} `);
155
+ clearCurrentLine();
156
+ const targetTag = activeTarget === "telegram" ? ` ${C.dim}[→ Tel]${C.reset}` : "";
157
+ process.stdout.write(`\n${C.bold}${C.brightBlue}Alvin Bot${targetTag}:${C.reset} `);
129
158
  }
130
159
  function printAssistantDelta(text) {
131
160
  process.stdout.write(text);
132
161
  }
133
162
  function printAssistantEnd(cost) {
134
163
  const costStr = cost && cost > 0 ? ` ${C.dim}($${cost.toFixed(4)})${C.reset}` : "";
135
- console.log(costStr);
164
+ process.stdout.write(costStr + "\n");
136
165
  }
137
166
  function printTool(name) {
138
- clearLine();
139
- process.stdout.write(`\r ${C.yellow}⚙ ${name}...${C.reset}`);
167
+ clearCurrentLine();
168
+ process.stdout.write(` ${C.yellow}⚙ ${name}...${C.reset}`);
140
169
  }
141
170
  function printToolDone() {
142
- clearLine();
171
+ clearCurrentLine();
143
172
  if (toolCount > 0) {
144
173
  const label = toolCount > 1 ? t("tui.toolsUsed") : t("tui.toolUsed");
145
174
  console.log(` ${C.dim}${C.yellow}⚙ ${toolCount} ${label}${C.reset}`);
@@ -147,19 +176,46 @@ function printToolDone() {
147
176
  toolCount = 0;
148
177
  }
149
178
  function printError(msg) {
179
+ clearCurrentLine();
150
180
  console.log(`\n${C.red}✖ ${msg}${C.reset}`);
151
181
  }
152
182
  function printInfo(msg) {
183
+ clearCurrentLine();
153
184
  console.log(`${C.cyan}ℹ ${msg}${C.reset}`);
154
185
  }
155
186
  function printSuccess(msg) {
187
+ clearCurrentLine();
156
188
  console.log(`${C.green}✔ ${msg}${C.reset}`);
157
189
  }
190
+ /**
191
+ * Render the mirror of a Telegram event (user message or bot response).
192
+ * Distinct styling: dim, phone prefix, grayed color.
193
+ */
194
+ function printMirrorUser(text) {
195
+ clearCurrentLine();
196
+ console.log(`\n${C.dim}${C.gray}📱 Tel User: ${text}${C.reset}`);
197
+ }
198
+ function printMirrorAssistantStart() {
199
+ clearCurrentLine();
200
+ process.stdout.write(`\n${C.dim}${C.gray}📱 Tel Bot: ${C.reset}`);
201
+ }
202
+ function printMirrorAssistantDelta(text) {
203
+ // Dim styling while streaming the mirrored response
204
+ process.stdout.write(`${C.dim}${C.gray}${text}${C.reset}`);
205
+ }
206
+ function printMirrorAssistantEnd() {
207
+ process.stdout.write("\n");
208
+ }
209
+ /**
210
+ * The single source of truth for rendering the input prompt. Only ever
211
+ * called at state-transition points (connect, done, error, command result)
212
+ * and no-ops during streaming so the prompt never races with delta writes.
213
+ */
158
214
  function showPrompt() {
159
- if (!isStreaming) {
160
- rl.setPrompt(`${C.brightGreen}❯${C.reset} `);
161
- rl.prompt();
162
- }
215
+ if (isStreaming || !rl)
216
+ return;
217
+ rl.setPrompt(`${C.brightGreen}❯${C.reset} `);
218
+ rl.prompt(true);
163
219
  }
164
220
  // ── WebSocket Connection ────────────────────────────────
165
221
  function connectWebSocket() {
@@ -223,7 +279,7 @@ function handleMessage(msg) {
223
279
  isStreaming = false;
224
280
  currentResponse = "";
225
281
  currentToolName = "";
226
- redrawHeader(); // Update cost in header
282
+ redrawHeader(); // Update cost in header (only if not streaming — see redrawHeader)
227
283
  showPrompt();
228
284
  break;
229
285
  case "error":
@@ -235,6 +291,41 @@ function handleMessage(msg) {
235
291
  printInfo(t("tui.sessionReset"));
236
292
  showPrompt();
237
293
  break;
294
+ // ── v4.5.0: Telegram activity mirror events ────────────────────────
295
+ // These arrive whenever someone interacts with the bot via Telegram,
296
+ // regardless of what the TUI is currently doing. We render them
297
+ // distinctly (dim + 📱 prefix) so they don't confuse themselves with
298
+ // the user's own session.
299
+ case "mirror:user_msg":
300
+ if (!observerEnabled)
301
+ break;
302
+ printMirrorUser(msg.text || "");
303
+ break;
304
+ case "mirror:response_start":
305
+ if (!observerEnabled)
306
+ break;
307
+ isMirrorStreaming = true;
308
+ printMirrorAssistantStart();
309
+ break;
310
+ case "mirror:response_delta":
311
+ if (!observerEnabled)
312
+ break;
313
+ if (!isMirrorStreaming) {
314
+ isMirrorStreaming = true;
315
+ printMirrorAssistantStart();
316
+ }
317
+ printMirrorAssistantDelta(msg.delta || "");
318
+ break;
319
+ case "mirror:response_done":
320
+ if (!observerEnabled)
321
+ break;
322
+ if (isMirrorStreaming) {
323
+ printMirrorAssistantEnd();
324
+ isMirrorStreaming = false;
325
+ }
326
+ // Don't call showPrompt here — the user's own prompt state is
327
+ // independent of mirror activity.
328
+ break;
238
329
  }
239
330
  }
240
331
  // ── API Calls ───────────────────────────────────────────
@@ -423,6 +514,40 @@ async function handleCommand(cmd) {
423
514
  ws.send(JSON.stringify({ type: "reset" }));
424
515
  }
425
516
  break;
517
+ case "target":
518
+ case "t": {
519
+ const val = (parts[1] || "").toLowerCase();
520
+ if (val === "tui") {
521
+ activeTarget = "tui";
522
+ printSuccess("Target: TUI (your own isolated session)");
523
+ redrawHeader();
524
+ }
525
+ else if (val === "telegram" || val === "tel") {
526
+ activeTarget = "telegram";
527
+ printSuccess("Target: Telegram (your messages now go into the Telegram session — the bot replies in Telegram AND here)");
528
+ redrawHeader();
529
+ }
530
+ else {
531
+ printInfo(`Current target: ${activeTarget}. Use /target tui or /target telegram.`);
532
+ }
533
+ break;
534
+ }
535
+ case "observe":
536
+ case "o": {
537
+ const val = (parts[1] || "").toLowerCase();
538
+ if (val === "on" || val === "1" || val === "true") {
539
+ observerEnabled = true;
540
+ printSuccess("Observer mode: ON — Telegram activity will be mirrored here (dim)");
541
+ }
542
+ else if (val === "off" || val === "0" || val === "false") {
543
+ observerEnabled = false;
544
+ printSuccess("Observer mode: OFF — Telegram activity will NOT be shown here");
545
+ }
546
+ else {
547
+ printInfo(`Observer: ${observerEnabled ? "on" : "off"}. Use /observe on or /observe off.`);
548
+ }
549
+ break;
550
+ }
426
551
  case "quit":
427
552
  case "q":
428
553
  case "exit":
@@ -442,7 +567,16 @@ function sendChat(text) {
442
567
  return;
443
568
  }
444
569
  printUser(text);
445
- ws.send(JSON.stringify({ type: "chat", text }));
570
+ // v4.5.0: include target + sessionKey so the web server routes the
571
+ // message to the right session. For target=tui, sessionKey is the
572
+ // TUI's own ephemeral (or persistent) key; for target=telegram,
573
+ // the server resolves it to the primary Telegram user's key.
574
+ ws.send(JSON.stringify({
575
+ type: "chat",
576
+ text,
577
+ target: activeTarget,
578
+ sessionKey: activeTarget === "tui" ? tuiSessionKey : undefined,
579
+ }));
446
580
  if (inputHistory[0] !== text) {
447
581
  inputHistory.unshift(text);
448
582
  if (inputHistory.length > 100)
@@ -464,9 +598,14 @@ async function fetchInitialModel() {
464
598
  catch { /* will get it on connect */ }
465
599
  }
466
600
  export async function startTUI() {
601
+ // --resume: use persistent TUI session (survives restarts).
602
+ // Default: ephemeral session, fresh every TUI start.
603
+ const wantResume = process.argv.includes("--resume");
604
+ tuiSessionKey = wantResume ? "tui:local" : `tui:ephemeral:${Date.now()}`;
467
605
  console.clear();
468
606
  drawHeader();
469
- console.log(`${C.dim}${t("tui.connecting")} ${baseUrl}...${C.reset}\n`);
607
+ console.log(`${C.dim}${t("tui.connecting")} ${baseUrl}...${C.reset}`);
608
+ console.log(`${C.dim}Session: ${wantResume ? "resuming tui:local (persistent)" : "new ephemeral session"}${C.reset}\n`);
470
609
  drawHelp();
471
610
  rl = createInterface({
472
611
  input: process.stdin,
@@ -495,9 +634,18 @@ export async function startTUI() {
495
634
  console.log(`\n${C.dim}${t("tui.bye")}${C.reset}\n`);
496
635
  process.exit(0);
497
636
  });
498
- if (process.stdin.isTTY) {
499
- process.stdin.setRawMode(false);
500
- }
637
+ // NOTE: Do NOT call process.stdin.setRawMode(false) here. readline with
638
+ // `terminal: true` already controls the terminal mode, and forcing cooked
639
+ // mode on top of that causes every keystroke to be echoed TWICE (once by
640
+ // the terminal, once by readline's line editor) — producing the classic
641
+ // "hheelllloo" double-echo bug. Let readline manage the tty mode itself.
642
+ // Handle terminal resize — redraw header to fit the new width.
643
+ process.stdout.on("resize", () => {
644
+ if (!isStreaming) {
645
+ redrawHeader();
646
+ showPrompt();
647
+ }
648
+ });
501
649
  await fetchInitialModel();
502
650
  connectWebSocket();
503
651
  }
@@ -28,6 +28,7 @@ import { handleDoctorAPI } from "./doctor-api.js";
28
28
  import { handleOpenAICompat } from "./openai-compat.js";
29
29
  import { addCanvasClient } from "./canvas.js";
30
30
  import { BOT_ROOT, ENV_FILE, PUBLIC_DIR, MEMORY_DIR, MEMORY_FILE, SOUL_FILE, DATA_DIR, MCP_CONFIG, SKILLS_DIR } from "../paths.js";
31
+ import { broadcast } from "../services/broadcast.js";
31
32
  const WEB_PORT = parseInt(process.env.WEB_PORT || "3100");
32
33
  const WEB_PASSWORD = process.env.WEB_PASSWORD || "";
33
34
  /** The actual port the Web UI is running on (may differ from WEB_PORT if busy). */
@@ -1144,6 +1145,57 @@ async function handleAPI(req, res, urlPath, body) {
1144
1145
  res.end(JSON.stringify({ error: "Not found" }));
1145
1146
  }
1146
1147
  // ── WebSocket Chat ──────────────────────────────────────
1148
+ // Set of all currently connected chat WebSocket clients (excluding canvas).
1149
+ // Populated on connect, cleaned up on close. Used to forward Telegram
1150
+ // activity to every observer.
1151
+ const chatClients = new Set();
1152
+ /**
1153
+ * Wire the broadcast bus once at module load. The bus is singleton, so
1154
+ * subscribing here means every Telegram message fan-outs to every connected
1155
+ * chat client — without any per-connection re-subscription.
1156
+ */
1157
+ broadcast.on("user_msg", (payload) => {
1158
+ if (payload.platform !== "telegram")
1159
+ return; // v4.5.0: telegram only for now
1160
+ const json = JSON.stringify({
1161
+ type: "mirror:user_msg",
1162
+ text: payload.text,
1163
+ platform: payload.platform,
1164
+ userName: payload.userName,
1165
+ ts: payload.ts,
1166
+ });
1167
+ for (const client of chatClients) {
1168
+ if (client.readyState === WebSocket.OPEN)
1169
+ client.send(json);
1170
+ }
1171
+ });
1172
+ broadcast.on("response_start", (payload) => {
1173
+ if (payload.platform !== "telegram")
1174
+ return;
1175
+ const json = JSON.stringify({ type: "mirror:response_start", platform: payload.platform, ts: payload.ts });
1176
+ for (const client of chatClients) {
1177
+ if (client.readyState === WebSocket.OPEN)
1178
+ client.send(json);
1179
+ }
1180
+ });
1181
+ broadcast.on("response_delta", (payload) => {
1182
+ if (payload.platform !== "telegram")
1183
+ return;
1184
+ const json = JSON.stringify({ type: "mirror:response_delta", delta: payload.delta, platform: payload.platform, ts: payload.ts });
1185
+ for (const client of chatClients) {
1186
+ if (client.readyState === WebSocket.OPEN)
1187
+ client.send(json);
1188
+ }
1189
+ });
1190
+ broadcast.on("response_done", (payload) => {
1191
+ if (payload.platform !== "telegram")
1192
+ return;
1193
+ const json = JSON.stringify({ type: "mirror:response_done", cost: payload.cost, platform: payload.platform, ts: payload.ts });
1194
+ for (const client of chatClients) {
1195
+ if (client.readyState === WebSocket.OPEN)
1196
+ client.send(json);
1197
+ }
1198
+ });
1147
1199
  function handleWebSocket(wss) {
1148
1200
  wss.on("connection", (ws, req) => {
1149
1201
  // Auth check
@@ -1158,12 +1210,33 @@ function handleWebSocket(wss) {
1158
1210
  return;
1159
1211
  }
1160
1212
  console.log("WebUI: client connected");
1213
+ chatClients.add(ws);
1161
1214
  ws.on("message", async (data) => {
1162
1215
  try {
1163
1216
  const msg = JSON.parse(data.toString());
1164
1217
  if (msg.type === "chat") {
1165
1218
  let { text, effort, file } = msg;
1166
- const userId = config.allowedUsers[0] || 0;
1219
+ // v4.5.0: session routing. The client (TUI/WebUI) tells us which
1220
+ // session it wants its message to go into. Supported targets:
1221
+ // - "tui" → use msg.sessionKey (e.g. "tui:local" or
1222
+ // "tui:ephemeral:…"). Isolated from Telegram.
1223
+ // - "telegram" → route into the primary Telegram user's session.
1224
+ // Responses go back to the client AND to the
1225
+ // actual Telegram chat via the broadcast bus.
1226
+ // - undefined → backwards-compatible: default to the primary
1227
+ // allowed user's session (old behavior).
1228
+ const target = msg.target;
1229
+ const telegramUserId = config.allowedUsers[0] || 0;
1230
+ let sessionKey;
1231
+ if (target === "tui" && typeof msg.sessionKey === "string" && msg.sessionKey.startsWith("tui:")) {
1232
+ sessionKey = msg.sessionKey;
1233
+ }
1234
+ else if (target === "telegram") {
1235
+ sessionKey = telegramUserId;
1236
+ }
1237
+ else {
1238
+ sessionKey = telegramUserId; // backwards compat
1239
+ }
1167
1240
  // Handle file upload — save to temp and reference in prompt
1168
1241
  if (file?.dataUrl && file?.name) {
1169
1242
  try {
@@ -1184,16 +1257,17 @@ function handleWebSocket(wss) {
1184
1257
  const registry = getRegistry();
1185
1258
  const activeProvider = registry.getActive();
1186
1259
  const isSDK = activeProvider.config.type === "claude-sdk";
1187
- const session = getSession(userId);
1260
+ const session = getSession(sessionKey);
1188
1261
  const queryOpts = {
1189
1262
  prompt: text,
1190
- systemPrompt: buildSystemPrompt(isSDK, session.language, "web-dashboard"),
1263
+ systemPrompt: buildSystemPrompt(isSDK, session.language, target === "telegram" ? "telegram" : "web-dashboard"),
1191
1264
  workingDir: session.workingDir,
1192
1265
  effort: effort || session.effort,
1193
1266
  sessionId: isSDK ? session.sessionId : null,
1194
1267
  history: !isSDK ? session.history : undefined,
1195
1268
  };
1196
1269
  let gotDone = false;
1270
+ let finalText = ""; // v4.5.0: capture the final response for target=telegram relay
1197
1271
  try {
1198
1272
  // Stream response
1199
1273
  for await (const chunk of registry.queryWithFallback(queryOpts)) {
@@ -1201,6 +1275,8 @@ function handleWebSocket(wss) {
1201
1275
  break;
1202
1276
  switch (chunk.type) {
1203
1277
  case "text":
1278
+ if (chunk.text)
1279
+ finalText = chunk.text;
1204
1280
  ws.send(JSON.stringify({ type: "text", text: chunk.text, delta: chunk.delta }));
1205
1281
  break;
1206
1282
  case "tool_use":
@@ -1208,6 +1284,8 @@ function handleWebSocket(wss) {
1208
1284
  break;
1209
1285
  case "done":
1210
1286
  gotDone = true;
1287
+ if (chunk.text)
1288
+ finalText = chunk.text;
1211
1289
  if (chunk.sessionId)
1212
1290
  session.sessionId = chunk.sessionId;
1213
1291
  if (chunk.costUsd)
@@ -1235,6 +1313,21 @@ function handleWebSocket(wss) {
1235
1313
  if (!gotDone && ws.readyState === WebSocket.OPEN) {
1236
1314
  ws.send(JSON.stringify({ type: "done", cost: 0 }));
1237
1315
  }
1316
+ // v4.5.0: if the user typed in the TUI with target=telegram, we
1317
+ // must also post the bot's final response to the actual Telegram
1318
+ // chat so the continuity is preserved from the Telegram side.
1319
+ // (Telegram bots cannot forge user messages, so only the
1320
+ // response lands in the chat — the user prompt itself stays
1321
+ // in the TUI.)
1322
+ if (target === "telegram" && finalText.trim()) {
1323
+ try {
1324
+ const dq = await import("../services/delivery-queue.js");
1325
+ dq.enqueue("telegram", String(telegramUserId), finalText);
1326
+ }
1327
+ catch (err) {
1328
+ console.error("WebUI → Telegram relay failed:", err);
1329
+ }
1330
+ }
1238
1331
  }
1239
1332
  catch (streamErr) {
1240
1333
  const errMsg = streamErr instanceof Error ? streamErr.message : String(streamErr);
@@ -1248,8 +1341,17 @@ function handleWebSocket(wss) {
1248
1341
  }
1249
1342
  }
1250
1343
  if (msg.type === "reset") {
1251
- const userId = config.allowedUsers[0] || 0;
1252
- resetSession(userId);
1344
+ // v4.5.0: reset the target session, not a hardcoded one.
1345
+ const target = msg.target;
1346
+ const telegramUserId = config.allowedUsers[0] || 0;
1347
+ let resetKey;
1348
+ if (target === "tui" && typeof msg.sessionKey === "string" && msg.sessionKey.startsWith("tui:")) {
1349
+ resetKey = msg.sessionKey;
1350
+ }
1351
+ else {
1352
+ resetKey = telegramUserId;
1353
+ }
1354
+ resetSession(resetKey);
1253
1355
  ws.send(JSON.stringify({ type: "reset", ok: true }));
1254
1356
  }
1255
1357
  }
@@ -1260,6 +1362,7 @@ function handleWebSocket(wss) {
1260
1362
  });
1261
1363
  ws.on("close", () => {
1262
1364
  console.log("WebUI: client disconnected");
1365
+ chatClients.delete(ws);
1263
1366
  });
1264
1367
  });
1265
1368
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "alvin-bot",
3
- "version": "4.4.7",
3
+ "version": "4.5.0",
4
4
  "description": "Alvin Bot — Your personal AI agent on Telegram, WhatsApp, Discord, Signal, and Web.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",