diff --git a/moltbot-2026.1.27-beta.3-patched.tgz b/moltbot-2026.1.27-beta.3-patched.tgz new file mode 100644 index 000000000..799f9fdd4 Binary files /dev/null and b/moltbot-2026.1.27-beta.3-patched.tgz differ diff --git a/package.json b/package.json index 04322f3af..919a2f73d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "moltbot", - "version": "2026.1.27-beta.1", + "version": "2026.1.27-beta.3-patched", "description": "WhatsApp gateway CLI (Baileys web) with Pi RPC agent", "type": "module", "main": "dist/index.js", diff --git a/src/gateway/auth.ts b/src/gateway/auth.ts index 1adc367a2..b7376acfa 100644 --- a/src/gateway/auth.ts +++ b/src/gateway/auth.ts @@ -17,8 +17,14 @@ export type GatewayAuthResult = { method?: "token" | "password" | "tailscale" | "device-token"; user?: string; reason?: string; + tokenHint?: string; }; +function maskToken(token: string): string { + if (token.length <= 8) return token.slice(0, 2) + "..." + token.slice(-2); + return token.slice(0, 4) + "..." + token.slice(-4); +} + type ConnectAuth = { token?: string; password?: string; @@ -229,7 +235,11 @@ export async function authorizeGatewayConnect(params: { return { ok: false, reason: "token_missing" }; } if (!safeEqual(connectAuth.token, auth.token)) { - return { ok: false, reason: "token_mismatch" }; + return { + ok: false, + reason: "token_mismatch", + tokenHint: `got=${maskToken(connectAuth.token)} expected=${maskToken(auth.token)}`, + }; } return { ok: true, method: "token" }; } diff --git a/src/gateway/server-chat.ts b/src/gateway/server-chat.ts index 8c67767a6..330a0bcae 100644 --- a/src/gateway/server-chat.ts +++ b/src/gateway/server-chat.ts @@ -134,10 +134,16 @@ export function createAgentEventHandler({ clearAgentRunContext, }: AgentEventHandlerOptions) { const emitChatDelta = (sessionKey: string, clientRunId: string, seq: number, text: string) => { + console.log( + `[DEBUG] emitChatDelta: sessionKey=${sessionKey} runId=${clientRunId} textLen=${text.length}`, + ); chatRunState.buffers.set(clientRunId, text); const now = Date.now(); const last = chatRunState.deltaSentAt.get(clientRunId) ?? 0; - if (now - last < 150) return; + if (now - last < 150) { + console.log(`[DEBUG] emitChatDelta: throttled (${now - last}ms < 150ms)`); + return; + } chatRunState.deltaSentAt.set(clientRunId, now); const payload = { runId: clientRunId, @@ -151,9 +157,13 @@ export function createAgentEventHandler({ }, }; // Suppress webchat broadcast for heartbeat runs when showOk is false - if (!shouldSuppressHeartbeatBroadcast(clientRunId)) { + const suppressed = shouldSuppressHeartbeatBroadcast(clientRunId); + console.log(`[DEBUG] emitChatDelta: heartbeatSuppressed=${suppressed}`); + if (!suppressed) { + console.log(`[DEBUG] emitChatDelta: calling broadcast("chat")`); broadcast("chat", payload, { dropIfSlow: true }); } + console.log(`[DEBUG] emitChatDelta: calling nodeSendToSession`); nodeSendToSession(sessionKey, "chat", payload); }; @@ -216,8 +226,11 @@ export function createAgentEventHandler({ }; return (evt: AgentEventPayload) => { + console.log(`[DEBUG] agent event: runId=${evt.runId} stream=${evt.stream} seq=${evt.seq}`); const chatLink = chatRunState.registry.peek(evt.runId); + console.log(`[DEBUG] chatLink: ${chatLink ? JSON.stringify(chatLink) : "null"}`); const sessionKey = chatLink?.sessionKey ?? resolveSessionKeyForRun(evt.runId); + console.log(`[DEBUG] sessionKey: ${sessionKey}`); const clientRunId = chatLink?.clientRunId ?? evt.runId; const isAborted = chatRunState.abortedRuns.has(clientRunId) || chatRunState.abortedRuns.has(evt.runId); @@ -249,7 +262,11 @@ export function createAgentEventHandler({ if (sessionKey) { nodeSendToSession(sessionKey, "agent", agentPayload); + console.log( + `[DEBUG] checking emitChatDelta: isAborted=${isAborted} stream=${evt.stream} hasText=${typeof evt.data?.text === "string"} text="${String(evt.data?.text ?? "").slice(0, 50)}..."`, + ); if (!isAborted && evt.stream === "assistant" && typeof evt.data?.text === "string") { + console.log(`[DEBUG] calling emitChatDelta`); emitChatDelta(sessionKey, clientRunId, evt.seq, evt.data.text); } else if (!isAborted && (lifecyclePhase === "end" || lifecyclePhase === "error")) { if (chatLink) { diff --git a/src/gateway/server-methods/chat.ts b/src/gateway/server-methods/chat.ts index 9010a6f21..a43ff34b3 100644 --- a/src/gateway/server-methods/chat.ts +++ b/src/gateway/server-methods/chat.ts @@ -9,6 +9,7 @@ import { resolveThinkingDefault } from "../../agents/model-selection.js"; import { resolveAgentTimeoutMs } from "../../agents/timeout.js"; import { dispatchInboundMessage } from "../../auto-reply/dispatch.js"; import { createReplyDispatcher } from "../../auto-reply/reply/reply-dispatcher.js"; +import { registerAgentRunContext } from "../../infra/agent-events.js"; import { extractShortModelName, type ResponsePrefixContext, @@ -362,13 +363,14 @@ export const chatHandlers: GatewayRequestHandlers = { return; } } - const { cfg, entry } = loadSessionEntry(p.sessionKey); + const { cfg, entry, canonicalKey } = loadSessionEntry(p.sessionKey); const timeoutMs = resolveAgentTimeoutMs({ cfg, overrideMs: p.timeoutMs, }); const now = Date.now(); const clientRunId = p.idempotencyKey; + registerAgentRunContext(clientRunId, { sessionKey: p.sessionKey }); const sendPolicy = resolveSendPolicy({ cfg, @@ -430,6 +432,10 @@ export const chatHandlers: GatewayRequestHandlers = { startedAtMs: now, expiresAtMs: resolveChatRunExpiresAtMs({ now, timeoutMs }), }); + context.addChatRun(clientRunId, { + sessionKey: p.sessionKey, + clientRunId, + }); const ackPayload = { runId: clientRunId, @@ -449,7 +455,7 @@ export const chatHandlers: GatewayRequestHandlers = { BodyForCommands: commandBody, RawBody: parsedMessage, CommandBody: commandBody, - SessionKey: p.sessionKey, + SessionKey: canonicalKey, Provider: INTERNAL_MESSAGE_CHANNEL, Surface: INTERNAL_MESSAGE_CHANNEL, OriginatingChannel: INTERNAL_MESSAGE_CHANNEL, diff --git a/src/gateway/server-restart-sentinel.ts b/src/gateway/server-restart-sentinel.ts index 28719290e..a093f6467 100644 --- a/src/gateway/server-restart-sentinel.ts +++ b/src/gateway/server-restart-sentinel.ts @@ -14,7 +14,24 @@ import { defaultRuntime } from "../runtime.js"; import { deliveryContextFromSession, mergeDeliveryContext } from "../utils/delivery-context.js"; import { loadSessionEntry } from "./session-utils.js"; -export async function scheduleRestartSentinelWake(params: { deps: CliDeps }) { +export type AddChatRunFn = ( + runId: string, + entry: { sessionKey: string; clientRunId: string }, +) => void; + +export type BroadcastFn = ( + event: string, + payload: unknown, + opts?: { dropIfSlow?: boolean }, +) => void; + +export interface RestartSentinelParams { + deps: CliDeps; + addChatRun?: AddChatRunFn; + broadcast?: BroadcastFn; +} + +export async function scheduleRestartSentinelWake(params: RestartSentinelParams) { const sentinel = await consumeRestartSentinel(); if (!sentinel) return; const payload = sentinel.payload; @@ -61,7 +78,27 @@ export async function scheduleRestartSentinelWake(params: { deps: CliDeps }) { const channel = channelRaw ? normalizeChannelId(channelRaw) : null; const to = origin?.to; if (!channel || !to) { + // No outbound channel (e.g., webchat sessions use WebSocket, not channel delivery). + // For webchat: register the run with addChatRun so responses stream to the client. enqueueSystemEvent(message, { sessionKey }); + if (params.addChatRun && params.broadcast) { + const runId = `restart-${Date.now()}`; + params.addChatRun(runId, { sessionKey, clientRunId: runId }); + try { + await agentCommand( + { + message: `System: ${summary}`, + sessionKey, + runId, + deliver: false, + }, + defaultRuntime, + params.deps, + ); + } catch { + // Agent turn failed; system event already queued as fallback context + } + } return; } diff --git a/src/gateway/server-startup.ts b/src/gateway/server-startup.ts index e6bdbb0dc..78d3a8321 100644 --- a/src/gateway/server-startup.ts +++ b/src/gateway/server-startup.ts @@ -19,6 +19,8 @@ import type { loadMoltbotPlugins } from "../plugins/loader.js"; import { type PluginServicesHandle, startPluginServices } from "../plugins/services.js"; import { startBrowserControlServerIfEnabled } from "./server-browser.js"; import { + type AddChatRunFn, + type BroadcastFn, scheduleRestartSentinelWake, shouldWakeFromRestartSentinel, } from "./server-restart-sentinel.js"; @@ -37,6 +39,8 @@ export async function startGatewaySidecars(params: { }; logChannels: { info: (msg: string) => void; error: (msg: string) => void }; logBrowser: { error: (msg: string) => void }; + addChatRun?: AddChatRunFn; + broadcast?: BroadcastFn; }) { // Start clawd browser control server (unless disabled via config). let browserControl: Awaited> = null; @@ -152,7 +156,11 @@ export async function startGatewaySidecars(params: { if (shouldWakeFromRestartSentinel()) { setTimeout(() => { - void scheduleRestartSentinelWake({ deps: params.deps }); + void scheduleRestartSentinelWake({ + deps: params.deps, + addChatRun: params.addChatRun, + broadcast: params.broadcast, + }); }, 750); } diff --git a/src/gateway/server.impl.ts b/src/gateway/server.impl.ts index f641c4076..a2f9a41ce 100644 --- a/src/gateway/server.impl.ts +++ b/src/gateway/server.impl.ts @@ -503,6 +503,8 @@ export async function startGatewayServer( logHooks, logChannels, logBrowser, + addChatRun, + broadcast, })); const { applyHotReload, requestGatewayRestart } = createGatewayReloadHandlers({ diff --git a/src/gateway/server/ws-connection/message-handler.ts b/src/gateway/server/ws-connection/message-handler.ts index d1f6ae511..37acde101 100644 --- a/src/gateway/server/ws-connection/message-handler.ts +++ b/src/gateway/server/ws-connection/message-handler.ts @@ -605,6 +605,7 @@ export function attachGatewayWsMessageHandler(params: { authMode: resolvedAuth.mode, authProvided, authReason: authResult.reason, + authTokenHint: authResult.tokenHint, allowTailscale: resolvedAuth.allowTailscale, client: connectParams.client.id, clientDisplayName: connectParams.client.displayName, diff --git a/src/telegram/bot-message-context.dm-threads.test.ts b/src/telegram/bot-message-context.dm-threads.test.ts index d710e0b1b..23dd92f21 100644 --- a/src/telegram/bot-message-context.dm-threads.test.ts +++ b/src/telegram/bot-message-context.dm-threads.test.ts @@ -56,6 +56,33 @@ describe("buildTelegramMessageContext dm thread sessions", () => { expect(ctx?.ctxPayload?.SessionKey).toBe("agent:main:main:thread:42"); }); + it("sets ThreadLabel for dm topics for display in Sessions tab", async () => { + const ctx = await buildContext({ + message_id: 1, + chat: { id: 1234, type: "private" }, + date: 1700000000, + text: "hello", + message_thread_id: 42, + from: { id: 42, first_name: "Alice" }, + }); + + expect(ctx).not.toBeNull(); + expect(ctx?.ctxPayload?.ThreadLabel).toBe("Thread: 42"); + }); + + it("does not set ThreadLabel for dm without thread id", async () => { + const ctx = await buildContext({ + message_id: 1, + chat: { id: 1234, type: "private" }, + date: 1700000000, + text: "hello", + from: { id: 42, first_name: "Alice" }, + }); + + expect(ctx).not.toBeNull(); + expect(ctx?.ctxPayload?.ThreadLabel).toBeUndefined(); + }); + it("keeps legacy dm session key when no thread id", async () => { const ctx = await buildContext({ message_id: 2, diff --git a/src/telegram/bot-message-context.ts b/src/telegram/bot-message-context.ts index 832a4413d..629a8dc44 100644 --- a/src/telegram/bot-message-context.ts +++ b/src/telegram/bot-message-context.ts @@ -161,6 +161,9 @@ export const buildTelegramMessageContext = async ({ isForum, messageThreadId, }); + // Effective thread ID for outbound delivery: groups use forum-resolved; DMs use raw messageThreadId + // (private chats never set is_forum=true per Telegram Bot API, so resolvedThreadId is always undefined for DMs) + const effectiveThreadId = isGroup ? resolvedThreadId : messageThreadId; const { groupConfig, topicConfig } = resolveTelegramGroupConfig(chatId, resolvedThreadId); const peerId = isGroup ? buildTelegramGroupPeerId(chatId, resolvedThreadId) : String(chatId); const route = resolveAgentRoute({ @@ -203,7 +206,8 @@ export const buildTelegramMessageContext = async ({ const sendTyping = async () => { await withTelegramApiErrorLogging({ operation: "sendChatAction", - fn: () => bot.api.sendChatAction(chatId, "typing", buildTypingThreadParams(resolvedThreadId)), + fn: () => + bot.api.sendChatAction(chatId, "typing", buildTypingThreadParams(effectiveThreadId)), }); }; @@ -212,7 +216,11 @@ export const buildTelegramMessageContext = async ({ await withTelegramApiErrorLogging({ operation: "sendChatAction", fn: () => - bot.api.sendChatAction(chatId, "record_voice", buildTypingThreadParams(resolvedThreadId)), + bot.api.sendChatAction( + chatId, + "record_voice", + buildTypingThreadParams(effectiveThreadId), + ), }); } catch (err) { logVerbose(`telegram record_voice cue failed for chat ${chatId}: ${String(err)}`); @@ -605,6 +613,8 @@ export const buildTelegramMessageContext = async ({ // For groups: use resolvedThreadId (forum topics only); for DMs: use raw messageThreadId MessageThreadId: isGroup ? resolvedThreadId : messageThreadId, IsForum: isForum, + // DM thread label for display in Sessions tab (e.g., "Sender Name (Thread: 42)") + ThreadLabel: !isGroup && messageThreadId != null ? `Thread: ${messageThreadId}` : undefined, // Originating channel for reply routing. OriginatingChannel: "telegram" as const, OriginatingTo: `telegram:${chatId}`, @@ -655,7 +665,7 @@ export const buildTelegramMessageContext = async ({ msg, chatId, isGroup, - resolvedThreadId, + resolvedThreadId: effectiveThreadId, isForum, historyKey, historyLimit, diff --git a/ui/src/ui/app-gateway.ts b/ui/src/ui/app-gateway.ts index b2355709c..662fbb94e 100644 --- a/ui/src/ui/app-gateway.ts +++ b/ui/src/ui/app-gateway.ts @@ -81,7 +81,12 @@ function normalizeSessionKeyForDefaults( } function applySessionDefaults(host: GatewayHost, defaults?: SessionDefaultsSnapshot) { - if (!defaults?.mainSessionKey) return; + console.log(`[DEBUG UI] applySessionDefaults: defaults=${JSON.stringify(defaults)}`); + console.log(`[DEBUG UI] applySessionDefaults: host.sessionKey="${host.sessionKey}" host.settings.sessionKey="${host.settings.sessionKey}"`); + if (!defaults?.mainSessionKey) { + console.log(`[DEBUG UI] applySessionDefaults: no mainSessionKey, returning early`); + return; + } const resolvedSessionKey = normalizeSessionKeyForDefaults(host.sessionKey, defaults); const resolvedSettingsSessionKey = normalizeSessionKeyForDefaults( host.settings.sessionKey, @@ -91,6 +96,7 @@ function applySessionDefaults(host: GatewayHost, defaults?: SessionDefaultsSnaps host.settings.lastActiveSessionKey, defaults, ); + console.log(`[DEBUG UI] applySessionDefaults: resolved="${resolvedSessionKey}" settingsResolved="${resolvedSettingsSessionKey}"`); const nextSessionKey = resolvedSessionKey || resolvedSettingsSessionKey || host.sessionKey; const nextSettings = { ...host.settings, @@ -100,7 +106,9 @@ function applySessionDefaults(host: GatewayHost, defaults?: SessionDefaultsSnaps const shouldUpdateSettings = nextSettings.sessionKey !== host.settings.sessionKey || nextSettings.lastActiveSessionKey !== host.settings.lastActiveSessionKey; + console.log(`[DEBUG UI] applySessionDefaults: nextSessionKey="${nextSessionKey}" shouldUpdate=${shouldUpdateSettings}`); if (nextSessionKey !== host.sessionKey) { + console.log(`[DEBUG UI] applySessionDefaults: updating host.sessionKey from "${host.sessionKey}" to "${nextSessionKey}"`); host.sessionKey = nextSessionKey; } if (shouldUpdateSettings) { diff --git a/ui/src/ui/app-lifecycle.ts b/ui/src/ui/app-lifecycle.ts index 71af9d202..40c6f0b1f 100644 --- a/ui/src/ui/app-lifecycle.ts +++ b/ui/src/ui/app-lifecycle.ts @@ -35,6 +35,11 @@ type LifecycleHost = { export function handleConnected(host: LifecycleHost) { host.basePath = inferBasePath(); + // Apply URL settings FIRST so sessionKey is read before syncTabWithLocation + // overwrites it with the localStorage value + applySettingsFromUrl( + host as unknown as Parameters[0], + ); syncTabWithLocation( host as unknown as Parameters[0], true, @@ -46,9 +51,6 @@ export function handleConnected(host: LifecycleHost) { host as unknown as Parameters[0], ); window.addEventListener("popstate", host.popStateHandler); - applySettingsFromUrl( - host as unknown as Parameters[0], - ); connectGateway(host as unknown as Parameters[0]); startNodesPolling(host as unknown as Parameters[0]); if (host.tab === "logs") { diff --git a/ui/src/ui/controllers/chat.ts b/ui/src/ui/controllers/chat.ts index 6a3e68175..9432feac7 100644 --- a/ui/src/ui/controllers/chat.ts +++ b/ui/src/ui/controllers/chat.ts @@ -159,8 +159,12 @@ export function handleChatEvent( state: ChatState, payload?: ChatEventPayload, ) { + console.log(`[DEBUG UI] handleChatEvent: payload.sessionKey="${payload?.sessionKey}" state.sessionKey="${state.sessionKey}"`); if (!payload) return null; - if (payload.sessionKey !== state.sessionKey) return null; + if (payload.sessionKey !== state.sessionKey) { + console.log(`[DEBUG UI] handleChatEvent: sessionKey mismatch, ignoring event`); + return null; + } // Final from another run (e.g. sub-agent announce): refresh history to show new message. // See https://github.com/moltbot/moltbot/issues/1909 diff --git a/ui/src/ui/gateway.ts b/ui/src/ui/gateway.ts index fc8dde08a..f763a1e44 100644 --- a/ui/src/ui/gateway.ts +++ b/ui/src/ui/gateway.ts @@ -220,7 +220,9 @@ export class GatewayBrowserClient { this.opts.onHello?.(hello); }) .catch(() => { - if (canFallbackToShared && deviceIdentity) { + // Always clear device auth token on connect failure when we have device identity. + // This handles stale tokens from previous gateway sessions that cause token_mismatch. + if (deviceIdentity) { clearDeviceAuthToken({ deviceId: deviceIdentity.deviceId, role }); } this.ws?.close(CONNECT_FAILED_CLOSE_CODE, "connect failed");