alvin-bot 4.4.7 → 4.5.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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,94 @@
2
2
 
3
3
  All notable changes to Alvin Bot are documented here.
4
4
 
5
+ ## [4.5.1] — 2026-04-09
6
+
7
+ ### 🐛 TUI Header Rendering Hotfix
8
+
9
+ **The header was appearing inline in the middle of the conversation after scrolling** — a follow-up bug to the 4.5.0 TUI fix. Reported from a live 4.5.0 Test MacBook session where the header popped up right after a long bot response.
10
+
11
+ **Root cause**: `redrawHeader()` in 4.5.0 used `\x1b[H` (move to top-left) + `\x1b[s`/`\x1b[u` (save/restore cursor) to update the header in place when cost/model/target changed. But `\x1b[H` resolves to the **current viewport top**, not the document top — and once the terminal has scrolled past the original header, the "viewport top" is somewhere in the middle of the conversation. So the header got re-rendered inline in the middle of the bot's output.
12
+
13
+ **Fix**: removed all `redrawHeader()` calls from mid-session code paths:
14
+ - `ws.on("open")` (connect): no redraw, header was already drawn at startup
15
+ - `ws.on("close")` (disconnect): no redraw, just the error message
16
+ - `case "done"` (after each bot response): no redraw (this was the primary bug site — it fired after every message)
17
+ - `case "model"` (model switch): no redraw, just a success info line
18
+ - `case "target tui|telegram"` (target switch): no redraw, just an info line
19
+ - `process.stdout.on("resize")`: no redraw, just re-renders the prompt line
20
+
21
+ The only remaining `redrawHeader()` call is inside `/clear`, which calls `console.clear()` first to wipe the whole buffer — the only context where an in-place redraw is safe.
22
+
23
+ The trade-off: the header no longer reflects live cost/model/target updates mid-session. You'll see the up-to-date values after the next `/clear` or on the next TUI start. In exchange, the conversation flow stays clean. A future release could add a proper status-line region using terminal scrolling regions if this becomes annoying.
24
+
25
+ ## [4.5.0] — 2026-04-09
26
+
27
+ ### 🐛 TUI Bug Fixes (critical — the old TUI was effectively unusable)
28
+
29
+ **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.
30
+
31
+ **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.
32
+
33
+ **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.
34
+
35
+ **Terminal resize handling** — Added `process.stdout.on("resize", …)` so the header redraws correctly when the user resizes the window (when safe).
36
+
37
+ ### ✨ New Feature: Parallel Observation + Session Routing
38
+
39
+ **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.
40
+
41
+ #### New in-process broadcast bus — `src/services/broadcast.ts`
42
+
43
+ A tiny typed `EventEmitter` with four event types:
44
+ - `user_msg` — a user sent a message on a platform (Telegram, WhatsApp, etc.)
45
+ - `response_start` — the bot started generating a response
46
+ - `response_delta` — a streaming text chunk
47
+ - `response_done` — the response is complete
48
+
49
+ 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.
50
+
51
+ #### TUI session isolation
52
+
53
+ The TUI now owns its own session key, completely separate from the Telegram user's session:
54
+
55
+ - **Default**: `alvin-bot tui` → fresh ephemeral session `tui:ephemeral:<timestamp>`. Every TUI start is a clean slate.
56
+ - **Persistent**: `alvin-bot tui --resume` → resumes `tui:local`, a long-lived session that survives TUI restarts.
57
+
58
+ Your Telegram conversation and your TUI conversation no longer overwrite each other's history. `/new` in the TUI only resets the TUI session.
59
+
60
+ #### `/target tui|telegram` — remote-control the Telegram session from TUI
61
+
62
+ New TUI command to switch where your typed messages go:
63
+
64
+ - **`/target tui`** (default) — Your messages go into the TUI's isolated session. Responses are rendered in the TUI only.
65
+ - **`/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`.
66
+
67
+ 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".
68
+
69
+ #### `/observe on|off` — mirror Telegram activity into the TUI
70
+
71
+ 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.
72
+
73
+ ### 🧠 Architecture / Design Note
74
+
75
+ 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.
76
+
77
+ ### 📦 Compatibility
78
+
79
+ 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).
80
+
81
+ ```bash
82
+ npm update -g alvin-bot
83
+ alvin-bot tui # fresh TUI session, observer on by default
84
+ alvin-bot tui --resume # resume persistent tui:local session
85
+ ```
86
+
87
+ Once inside TUI:
88
+ - `/target telegram` — route your messages into the Telegram session (responses land in both TUI and Telegram chat)
89
+ - `/target tui` — switch back to isolated TUI session (default)
90
+ - `/observe off` — stop mirroring Telegram activity
91
+ - `/observe on` — resume mirroring
92
+
5
93
  ## [4.4.7] — 2026-04-09
6
94
 
7
95
  ### 🔐 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,76 @@ 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
  }
94
- 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
99
- for (let i = 0; i < HEADER_LINES; i++) {
100
- process.stdout.write("\x1b[K"); // Clear line
101
- if (i < HEADER_LINES - 1)
102
- process.stdout.write("\x1b[1B"); // Move down
107
+ /**
108
+ * Redraw the header. The old "in-place" implementation used cursor save/
109
+ * restore escape sequences and jumped to \x1b[H but once the terminal
110
+ * has scrolled past the original header, \x1b[H resolves to the current
111
+ * viewport top (not the document top), which means the header gets
112
+ * re-rendered inline in the middle of the content. That's what produced
113
+ * the "header appears in the middle of the bot response" bug in 4.5.0.
114
+ *
115
+ * The only safe way to redraw the header in a scrolling terminal is to
116
+ * clear the whole screen and redraw from scratch. Do that only in
117
+ * explicit reset contexts (/clear, SIGWINCH resize, initial connect).
118
+ * For mid-session cost/status updates, use inline info messages instead.
119
+ */
120
+ function redrawHeader(opts = {}) {
121
+ if (isStreaming)
122
+ return;
123
+ if (opts.clearScreen) {
124
+ console.clear();
103
125
  }
104
- process.stdout.write("\x1b[H"); // Back to top
105
126
  drawHeader();
106
- process.stdout.write("\x1b8"); // Restore cursor position
127
+ if (rl && !isStreaming)
128
+ rl.prompt(true);
107
129
  }
108
130
  function drawHelp() {
109
131
  console.log(`
110
132
  ${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")}
133
+ ${C.cyan}/model${C.reset} ${t("help.model")}
134
+ ${C.cyan}/status${C.reset} ${t("help.status")}
135
+ ${C.cyan}/clear${C.reset} ${t("help.clear")}
136
+ ${C.cyan}/cron${C.reset} ${t("help.cron")}
137
+ ${C.cyan}/doctor${C.reset} ${t("help.doctor")}
138
+ ${C.cyan}/backup${C.reset} ${t("help.backup")}
139
+ ${C.cyan}/restart${C.reset} ${t("help.restart")}
140
+ ${C.cyan}/target tui${C.reset}|${C.cyan}telegram${C.reset} Switch where your messages go
141
+ ${C.cyan}/observe on${C.reset}|${C.cyan}off${C.reset} Mirror Telegram activity (default: on)
142
+ ${C.cyan}/help${C.reset} ${t("help.help")}
143
+ ${C.cyan}/quit${C.reset} ${t("help.quit")}
120
144
 
121
145
  ${C.dim}${t("help.footer")}${C.reset}
122
146
  `);
123
147
  }
124
148
  function printUser(text) {
149
+ clearCurrentLine();
125
150
  console.log(`\n${C.bold}${C.brightGreen}${t("tui.you")}:${C.reset} ${text}`);
126
151
  }
127
152
  function printAssistantStart() {
128
- process.stdout.write(`\n${C.bold}${C.brightBlue}Alvin Bot:${C.reset} `);
153
+ clearCurrentLine();
154
+ const targetTag = activeTarget === "telegram" ? ` ${C.dim}[→ Tel]${C.reset}` : "";
155
+ process.stdout.write(`\n${C.bold}${C.brightBlue}Alvin Bot${targetTag}:${C.reset} `);
129
156
  }
130
157
  function printAssistantDelta(text) {
131
158
  process.stdout.write(text);
132
159
  }
133
160
  function printAssistantEnd(cost) {
134
161
  const costStr = cost && cost > 0 ? ` ${C.dim}($${cost.toFixed(4)})${C.reset}` : "";
135
- console.log(costStr);
162
+ process.stdout.write(costStr + "\n");
136
163
  }
137
164
  function printTool(name) {
138
- clearLine();
139
- process.stdout.write(`\r ${C.yellow}⚙ ${name}...${C.reset}`);
165
+ clearCurrentLine();
166
+ process.stdout.write(` ${C.yellow}⚙ ${name}...${C.reset}`);
140
167
  }
141
168
  function printToolDone() {
142
- clearLine();
169
+ clearCurrentLine();
143
170
  if (toolCount > 0) {
144
171
  const label = toolCount > 1 ? t("tui.toolsUsed") : t("tui.toolUsed");
145
172
  console.log(` ${C.dim}${C.yellow}⚙ ${toolCount} ${label}${C.reset}`);
@@ -147,26 +174,54 @@ function printToolDone() {
147
174
  toolCount = 0;
148
175
  }
149
176
  function printError(msg) {
177
+ clearCurrentLine();
150
178
  console.log(`\n${C.red}✖ ${msg}${C.reset}`);
151
179
  }
152
180
  function printInfo(msg) {
181
+ clearCurrentLine();
153
182
  console.log(`${C.cyan}ℹ ${msg}${C.reset}`);
154
183
  }
155
184
  function printSuccess(msg) {
185
+ clearCurrentLine();
156
186
  console.log(`${C.green}✔ ${msg}${C.reset}`);
157
187
  }
188
+ /**
189
+ * Render the mirror of a Telegram event (user message or bot response).
190
+ * Distinct styling: dim, phone prefix, grayed color.
191
+ */
192
+ function printMirrorUser(text) {
193
+ clearCurrentLine();
194
+ console.log(`\n${C.dim}${C.gray}📱 Tel User: ${text}${C.reset}`);
195
+ }
196
+ function printMirrorAssistantStart() {
197
+ clearCurrentLine();
198
+ process.stdout.write(`\n${C.dim}${C.gray}📱 Tel Bot: ${C.reset}`);
199
+ }
200
+ function printMirrorAssistantDelta(text) {
201
+ // Dim styling while streaming the mirrored response
202
+ process.stdout.write(`${C.dim}${C.gray}${text}${C.reset}`);
203
+ }
204
+ function printMirrorAssistantEnd() {
205
+ process.stdout.write("\n");
206
+ }
207
+ /**
208
+ * The single source of truth for rendering the input prompt. Only ever
209
+ * called at state-transition points (connect, done, error, command result)
210
+ * and no-ops during streaming so the prompt never races with delta writes.
211
+ */
158
212
  function showPrompt() {
159
- if (!isStreaming) {
160
- rl.setPrompt(`${C.brightGreen}❯${C.reset} `);
161
- rl.prompt();
162
- }
213
+ if (isStreaming || !rl)
214
+ return;
215
+ rl.setPrompt(`${C.brightGreen}❯${C.reset} `);
216
+ rl.prompt(true);
163
217
  }
164
218
  // ── WebSocket Connection ────────────────────────────────
165
219
  function connectWebSocket() {
166
220
  ws = new WebSocket(wsUrl);
167
221
  ws.on("open", () => {
168
222
  connected = true;
169
- redrawHeader();
223
+ // No header redraw here — the header was already drawn at startTUI().
224
+ // Calling redrawHeader() in a scrolled terminal re-renders it inline.
170
225
  printInfo(t("tui.connectedTo"));
171
226
  showPrompt();
172
227
  });
@@ -180,7 +235,7 @@ function connectWebSocket() {
180
235
  ws.on("close", () => {
181
236
  connected = false;
182
237
  isStreaming = false;
183
- redrawHeader();
238
+ // No header redraw — it would appear inline mid-chat.
184
239
  printError(t("tui.connectionLost"));
185
240
  setTimeout(connectWebSocket, 3000);
186
241
  });
@@ -223,7 +278,10 @@ function handleMessage(msg) {
223
278
  isStreaming = false;
224
279
  currentResponse = "";
225
280
  currentToolName = "";
226
- redrawHeader(); // Update cost in header
281
+ // NOTE: do NOT call redrawHeader() here. On a scrolled terminal it
282
+ // renders the header inline at the viewport top, which looks like
283
+ // the header appeared in the middle of the conversation. The total
284
+ // cost is already shown inline at the end of each response.
227
285
  showPrompt();
228
286
  break;
229
287
  case "error":
@@ -235,6 +293,41 @@ function handleMessage(msg) {
235
293
  printInfo(t("tui.sessionReset"));
236
294
  showPrompt();
237
295
  break;
296
+ // ── v4.5.0: Telegram activity mirror events ────────────────────────
297
+ // These arrive whenever someone interacts with the bot via Telegram,
298
+ // regardless of what the TUI is currently doing. We render them
299
+ // distinctly (dim + 📱 prefix) so they don't confuse themselves with
300
+ // the user's own session.
301
+ case "mirror:user_msg":
302
+ if (!observerEnabled)
303
+ break;
304
+ printMirrorUser(msg.text || "");
305
+ break;
306
+ case "mirror:response_start":
307
+ if (!observerEnabled)
308
+ break;
309
+ isMirrorStreaming = true;
310
+ printMirrorAssistantStart();
311
+ break;
312
+ case "mirror:response_delta":
313
+ if (!observerEnabled)
314
+ break;
315
+ if (!isMirrorStreaming) {
316
+ isMirrorStreaming = true;
317
+ printMirrorAssistantStart();
318
+ }
319
+ printMirrorAssistantDelta(msg.delta || "");
320
+ break;
321
+ case "mirror:response_done":
322
+ if (!observerEnabled)
323
+ break;
324
+ if (isMirrorStreaming) {
325
+ printMirrorAssistantEnd();
326
+ isMirrorStreaming = false;
327
+ }
328
+ // Don't call showPrompt here — the user's own prompt state is
329
+ // independent of mirror activity.
330
+ break;
238
331
  }
239
332
  }
240
333
  // ── API Calls ───────────────────────────────────────────
@@ -304,7 +397,8 @@ async function handleCommand(cmd) {
304
397
  if (res.ok) {
305
398
  currentModel = res.active || parts[1];
306
399
  printSuccess(`${t("tui.switchedTo")}: ${currentModel}`);
307
- redrawHeader();
400
+ // Header stays as-is (would appear inline otherwise)
401
+ // next /clear redraws it with the new model.
308
402
  }
309
403
  else {
310
404
  printError(res.error || t("tui.switchError"));
@@ -417,12 +511,49 @@ async function handleCommand(cmd) {
417
511
  }
418
512
  case "clear":
419
513
  case "c":
420
- console.clear();
421
- drawHeader();
514
+ // /clear is the ONLY command that safely redraws the header, because
515
+ // it wipes the entire screen first.
516
+ redrawHeader({ clearScreen: true });
422
517
  if (ws?.readyState === WebSocket.OPEN) {
423
- ws.send(JSON.stringify({ type: "reset" }));
518
+ ws.send(JSON.stringify({
519
+ type: "reset",
520
+ target: activeTarget,
521
+ sessionKey: activeTarget === "tui" ? tuiSessionKey : undefined,
522
+ }));
424
523
  }
425
524
  break;
525
+ case "target":
526
+ case "t": {
527
+ const val = (parts[1] || "").toLowerCase();
528
+ if (val === "tui") {
529
+ activeTarget = "tui";
530
+ printSuccess("Target: TUI (your own isolated session)");
531
+ }
532
+ else if (val === "telegram" || val === "tel") {
533
+ activeTarget = "telegram";
534
+ printSuccess("Target: Telegram (your messages now go into the Telegram session — the bot replies in Telegram AND here)");
535
+ }
536
+ else {
537
+ printInfo(`Current target: ${activeTarget}. Use /target tui or /target telegram.`);
538
+ }
539
+ break;
540
+ }
541
+ case "observe":
542
+ case "o": {
543
+ const val = (parts[1] || "").toLowerCase();
544
+ if (val === "on" || val === "1" || val === "true") {
545
+ observerEnabled = true;
546
+ printSuccess("Observer mode: ON — Telegram activity will be mirrored here (dim)");
547
+ }
548
+ else if (val === "off" || val === "0" || val === "false") {
549
+ observerEnabled = false;
550
+ printSuccess("Observer mode: OFF — Telegram activity will NOT be shown here");
551
+ }
552
+ else {
553
+ printInfo(`Observer: ${observerEnabled ? "on" : "off"}. Use /observe on or /observe off.`);
554
+ }
555
+ break;
556
+ }
426
557
  case "quit":
427
558
  case "q":
428
559
  case "exit":
@@ -442,7 +573,16 @@ function sendChat(text) {
442
573
  return;
443
574
  }
444
575
  printUser(text);
445
- ws.send(JSON.stringify({ type: "chat", text }));
576
+ // v4.5.0: include target + sessionKey so the web server routes the
577
+ // message to the right session. For target=tui, sessionKey is the
578
+ // TUI's own ephemeral (or persistent) key; for target=telegram,
579
+ // the server resolves it to the primary Telegram user's key.
580
+ ws.send(JSON.stringify({
581
+ type: "chat",
582
+ text,
583
+ target: activeTarget,
584
+ sessionKey: activeTarget === "tui" ? tuiSessionKey : undefined,
585
+ }));
446
586
  if (inputHistory[0] !== text) {
447
587
  inputHistory.unshift(text);
448
588
  if (inputHistory.length > 100)
@@ -464,9 +604,14 @@ async function fetchInitialModel() {
464
604
  catch { /* will get it on connect */ }
465
605
  }
466
606
  export async function startTUI() {
607
+ // --resume: use persistent TUI session (survives restarts).
608
+ // Default: ephemeral session, fresh every TUI start.
609
+ const wantResume = process.argv.includes("--resume");
610
+ tuiSessionKey = wantResume ? "tui:local" : `tui:ephemeral:${Date.now()}`;
467
611
  console.clear();
468
612
  drawHeader();
469
- console.log(`${C.dim}${t("tui.connecting")} ${baseUrl}...${C.reset}\n`);
613
+ console.log(`${C.dim}${t("tui.connecting")} ${baseUrl}...${C.reset}`);
614
+ console.log(`${C.dim}Session: ${wantResume ? "resuming tui:local (persistent)" : "new ephemeral session"}${C.reset}\n`);
470
615
  drawHelp();
471
616
  rl = createInterface({
472
617
  input: process.stdin,
@@ -495,9 +640,18 @@ export async function startTUI() {
495
640
  console.log(`\n${C.dim}${t("tui.bye")}${C.reset}\n`);
496
641
  process.exit(0);
497
642
  });
498
- if (process.stdin.isTTY) {
499
- process.stdin.setRawMode(false);
500
- }
643
+ // NOTE: Do NOT call process.stdin.setRawMode(false) here. readline with
644
+ // `terminal: true` already controls the terminal mode, and forcing cooked
645
+ // mode on top of that causes every keystroke to be echoed TWICE (once by
646
+ // the terminal, once by readline's line editor) — producing the classic
647
+ // "hheelllloo" double-echo bug. Let readline manage the tty mode itself.
648
+ // Handle terminal resize — we can't safely redraw the header in place
649
+ // on a scrolled buffer. Just re-render the prompt so readline picks up
650
+ // the new width for its line editor.
651
+ process.stdout.on("resize", () => {
652
+ if (!isStreaming)
653
+ showPrompt();
654
+ });
501
655
  await fetchInitialModel();
502
656
  connectWebSocket();
503
657
  }
@@ -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.1",
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",