/** * ChatController — manages the set of Grok sessions a single Telegram chat is * controlling, with exactly one "foreground" session streaming live. Other * (background) sessions keep running quietly; their output lands in the * session's .jsonl and is replayed as "unread" when you switch to them. */ import { basename } from "node:path"; import type { Api } from "grammy"; import type { GrokClient } from "../grok/client.js"; import type { SettingsStore } from "../app/settings-store.js"; import type { AppConfig } from "../config.js"; import { jsonlSize, readEntriesFrom, readHistory } from "../sessions/history.js"; import type { SessionStore } from "../sessions/store.js"; import type { HistoryEntry } from "../sessions/types.js"; import type { AccountRotator } from "./account-rotator.js"; import { SessionRuntime } from "./session-runtime.js"; export interface RunningSession { sessionId?: string; projectName: string; busy: boolean; foreground: boolean; unread: number; /** Latest task-completion % (0–100) for this session, if known. */ progress?: number; /** Card comment: last user prompt (+ last AI thinking while busy). */ comment?: string; } export interface SwitchResult { rt: SessionRuntime; sessionId?: string; projectName?: string; busy: boolean; unread: HistoryEntry[]; firstView: boolean; alreadyForeground: boolean; } export interface ChatControllerOpts { /** Forum topic thread — all runtimes post into this topic. */ messageThreadId?: number; /** Settings key (`chatId` or `chatId:t{thread}`). Defaults to String(chatId). */ settingsKey?: string; /** Fixed project path (forum topics): never switch away via project picker. */ fixedCwd?: string; fixedProjectName?: string; } /** Optional Telegram bridge services attached to every runtime in this chat. */ export type ChatBridgeServices = SessionRuntime["bridge"]; export class ChatController { private readonly runtimes: SessionRuntime[] = []; private fg: SessionRuntime | undefined; private readonly lastRead = new Map(); private restored = false; readonly settingsKey: string; readonly messageThreadId: number | undefined; readonly fixedCwd: string | undefined; readonly fixedProjectName: string | undefined; /** Telegram bridge (forum / memory / sibling bots); set by the registry. */ bridge?: ChatBridgeServices; /** * General manager: map Telegram message ids (user + bot) → session id so a * reply-to continues the same ACP session instead of spawning a new one. */ private readonly telegramMsgSessions = new Map(); constructor( private readonly api: Api, private readonly chatId: number, private readonly acp: GrokClient, private readonly cfg: AppConfig, private readonly settings: SettingsStore, private readonly store: SessionStore, private readonly refresh: (chatId: number) => void, private readonly notifyActivity: (busy: boolean) => void, private readonly getRotator?: () => AccountRotator | undefined, opts?: ChatControllerOpts, ) { this.messageThreadId = opts?.messageThreadId; this.settingsKey = opts?.settingsKey ?? String(chatId); this.fixedCwd = opts?.fixedCwd; this.fixedProjectName = opts?.fixedProjectName; } /** The current foreground runtime (created/restored lazily). */ foreground(): SessionRuntime { this.ensureRestored(); if (!this.fg) { const s = this.settings.getKey(this.settingsKey); const cwd = this.fixedCwd ?? s.projectPath ?? this.cfg.workspace; const name = this.fixedProjectName ?? s.projectName; const rt = this.create({ cwd, projectName: name, sessionId: s.sessionId }); this.runtimes.push(rt); this.fg = rt; } return this.fg; } /** List the controlled sessions (for /running). */ list(): RunningSession[] { this.ensureRestored(); this.pruneDuplicates(); return this.runtimes.map((rt) => ({ sessionId: rt.sessionId, projectName: rt.projectName ?? basename(rt.cwd), busy: rt.isBusy, foreground: rt.isForeground, unread: this.unreadCount(rt), progress: rt.taskProgress, comment: rt.cardComment, })); } /** Start a brand-new session and bring it to the foreground. * Always binds a live ACP session (`session/new`) — use {@link switchProject} * when you only need to change the working directory (instant). */ async addNew(cwd: string, projectName?: string): Promise { this.ensureRestored(); const prevFg = this.fg; const rt = this.create({ cwd, projectName }); this.runtimes.push(rt); this.fg = rt; // Fire-and-forget: finalizing the previous streamer must not block the new // session bind (that was a major source of "bot freezes on switch"). void this.background(prevFg); await rt.startNewSession(cwd, projectName); this.markSeen(rt); this.persist(); return rt; } /** * General manager: spawn a fresh session for one user message without * killing an in-flight sibling's Telegram stream (parallel prompts). */ async addParallel(cwd: string, projectName?: string): Promise { return this.addNew(cwd, projectName); } /** Remember that a Telegram message belongs to a Grok session (reply routing). */ bindTelegramMessage(messageId: number, sessionId: string): void { if (!messageId || !sessionId) return; this.telegramMsgSessions.set(messageId, sessionId); // Cap map growth (keep newest ~500). if (this.telegramMsgSessions.size > 500) { const drop = this.telegramMsgSessions.size - 500; let i = 0; for (const k of this.telegramMsgSessions.keys()) { this.telegramMsgSessions.delete(k); if (++i >= drop) break; } } } /** Resolve a controlled runtime from a Telegram message id (reply-to). */ runtimeForTelegramMessage(messageId: number | undefined): SessionRuntime | undefined { if (messageId === undefined) return undefined; this.ensureRestored(); const sid = this.telegramMsgSessions.get(messageId); if (sid) { const byId = this.runtimes.find((r) => r.sessionId === sid); if (byId) return byId; } return undefined; } /** * Resolve session from `#sess_xxxxxxxx` in a replied-to bot message body * (works across process restarts when the in-memory map is cold). */ runtimeForSessionTag(text: string | undefined): SessionRuntime | undefined { if (!text) return undefined; this.ensureRestored(); const tag = parseSessTag(text); if (!tag) return undefined; return this.runtimes.find((r) => r.sessionId && sessionMatchesTag(r.sessionId, tag)); } /** * Cold-start / map-miss: resolve continue target from reply message id, #sess_ * tag on controlled runtimes, or disk session list (attach into this controller). */ async resolveContinueFromReply(opts: { replyToMessageId?: number; replyToText?: string; cwd: string; projectName?: string; }): Promise { this.ensureRestored(); const byMsg = this.runtimeForTelegramMessage(opts.replyToMessageId); if (byMsg) return byMsg; const byTag = this.runtimeForSessionTag(opts.replyToText); if (byTag) return byTag; const tag = parseSessTag(opts.replyToText); if (!tag) return undefined; // Disk fallback: find full session id, then resume into this controller. let metas; try { metas = this.store.list(80); } catch { return undefined; } const meta = metas.find((m) => sessionMatchesTag(m.sessionId, tag)); if (!meta) return undefined; try { const sw = await this.addResume( meta.sessionId, meta.cwd || opts.cwd, opts.projectName, ); return sw.rt; } catch { return undefined; } } /** Find any runtime that still holds this suggestion batch (General parallel). */ takeSuggestionAnywhere( batchId: number, index: number, ): { rt: SessionRuntime; text: string } | undefined { this.ensureRestored(); for (const r of this.runtimes) { const text = r.takeSuggestion(batchId, index); if (text) return { rt: r, text }; } return undefined; } /** * Switch the chat to a project directory **without** waiting on ACP. * - Reuses an existing controlled runtime for the same path when possible. * - Does **not** call `session/new` — the live session is created lazily on * the first prompt / prepare (via `ensureSession`). * This keeps the project picker responsive even while another turn is running. */ async switchProject(cwd: string, projectName?: string): Promise { this.ensureRestored(); const key = normPath(cwd); const same = this.runtimes.filter((r) => normPath(r.cwd) === key); // Prefer the current FG if it already points here, else the most recent match. const existing = same.find((r) => r === this.fg) ?? same.at(-1); if (existing) { if (projectName) existing.projectName = projectName; if (existing === this.fg) { this.persist(); return existing; } if (existing.sessionId) { // Fast path: switchTo no longer awaits ACP re-bind. const sw = await this.switchTo(existing.sessionId); return sw?.rt ?? existing; } void this.background(this.fg); this.fg = existing; await existing.setForeground(true); this.persist(); return existing; } const prevFg = this.fg; const rt = this.create({ cwd, projectName }); this.runtimes.push(rt); this.fg = rt; void this.background(prevFg); // Drop other never-used project placeholders (no session yet) so rapid // project browsing can't accumulate infinite idle runtimes/listeners. this.pruneUnusedPlaceholders(rt); // No startNewSession — sessionId stays undefined until the first message. this.persist(); return rt; } /** * Import a foreign session (Kiro / OpenCode / Claude / Codex) as a new * controlled Grok session in the same project, primed with the full transcript. * The new session becomes the foreground /running entry. */ async addImport( cwd: string, projectName: string | undefined, priming: string, ): Promise { this.ensureRestored(); const prevFg = this.fg; const rt = this.create({ cwd, projectName }); this.runtimes.push(rt); this.fg = rt; void this.background(prevFg); await rt.startImportedSession(cwd, projectName, priming); this.markSeen(rt); this.persist(); return rt; } /** * Connect to a session with resume-or-fork semantics (used by /sessions), * adding it as a controlled session and bringing it to the foreground. */ async addAttach( sessionId: string, cwd: string, projectName: string | undefined, priorEntries: HistoryEntry[], ): Promise<{ rt: SessionRuntime; result: "resumed" | "forked"; alreadyControlled: boolean }> { this.ensureRestored(); if (this.runtimes.some((r) => r.sessionId === sessionId)) { const sw = await this.switchTo(sessionId); return { rt: sw!.rt, result: "resumed", alreadyControlled: true }; } // Reserve the runtime synchronously (before any await) so a concurrent tap // on the same session finds it and switches instead of creating a duplicate. const prevFg = this.fg; const rt = this.create({ cwd, projectName, sessionId }); this.runtimes.push(rt); this.fg = rt; void this.background(prevFg); const result = await rt.attach(sessionId, cwd, projectName, priorEntries); this.markSeen(rt); this.persist(); return { rt, result, alreadyControlled: false }; } /** Connect to an existing session: switch if already controlled, else add it. */ async addResume(sessionId: string, cwd: string, projectName?: string): Promise { this.ensureRestored(); if (this.runtimes.some((r) => r.sessionId === sessionId)) { return (await this.switchTo(sessionId))!; } const prevFg = this.fg; const rt = this.create({ cwd, projectName, sessionId }); this.runtimes.push(rt); this.fg = rt; void this.background(prevFg); // Lazy re-bind on first prompt (rebindPending); don't block the resume UI. const path = this.store.jsonlPath(sessionId); const unread = readHistory(path, 12); this.lastRead.set(sessionId, jsonlSize(path)); this.persist(); return { rt, sessionId, projectName, busy: rt.isBusy, unread, firstView: true, alreadyForeground: false }; } /** Switch the foreground to an already-controlled session. */ async switchTo(sessionId: string): Promise { this.ensureRestored(); const rt = this.runtimes.find((r) => r.sessionId === sessionId); if (!rt) return undefined; if (rt === this.fg) { return { rt, sessionId, projectName: rt.projectName, busy: rt.isBusy, unread: [], firstView: false, alreadyForeground: true }; } void this.background(this.fg); this.fg = rt; await rt.setForeground(true); // Do NOT await prepare()/loadSession here — re-bind is lazy on the next // prompt (rebindPending). Awaiting ACP mid-switch freezes the bot when the // agent is busy with another turn. const path = this.store.jsonlPath(sessionId); const seen = this.lastRead.get(sessionId); let unread: HistoryEntry[] = []; let firstView = false; if (seen !== undefined) { unread = readEntriesFrom(path, seen).entries; } else { unread = readHistory(path, 12); firstView = true; } this.lastRead.set(sessionId, jsonlSize(path)); // No tail-watch here: setForeground(true) above already resumed RICH live // streaming for the in-flight turn via the agent's own session/update // events. Tailing the .jsonl too would double-render every update. this.persist(); return { rt, sessionId, projectName: rt.projectName, busy: rt.isBusy, unread, firstView, alreadyForeground: false }; } /** Stop controlling a session (does not kill it). */ async close(sessionId: string): Promise { this.ensureRestored(); const idx = this.runtimes.findIndex((r) => r.sessionId === sessionId); if (idx === -1) return false; const rt = this.runtimes[idx]!; rt.dispose(); this.runtimes.splice(idx, 1); this.lastRead.delete(sessionId); if (this.fg === rt) { this.fg = this.runtimes[0]; if (this.fg) await this.fg.setForeground(true); } this.persist(); return true; } count(): number { this.ensureRestored(); return this.runtimes.length; } /** Latest task-progress % for a controlled session id, if this chat runs it. */ progressFor(sessionId?: string): number | undefined { if (!sessionId) return undefined; this.ensureRestored(); return this.runtimes.find((r) => r.sessionId === sessionId)?.taskProgress; } /** Last user prompt (+ thinking when busy) for a controlled session id. */ commentFor(sessionId?: string): string | undefined { if (!sessionId) return undefined; this.ensureRestored(); return this.runtimes.find((r) => r.sessionId === sessionId)?.cardComment; } findBySession(sessionId: string): boolean { return this.runtimes.some((r) => r.sessionId === sessionId); } dispose(): void { for (const rt of this.runtimes) rt.dispose(); this.runtimes.length = 0; this.fg = undefined; } // ── internals ────────────────────────────────────────────────────────────── private ensureRestored(): void { if (this.restored) return; this.restored = true; const s = this.settings.getKey(this.settingsKey); const seen = new Set(); for (const cs of s.controlledSessions ?? []) { if (!cs.sessionId || seen.has(cs.sessionId)) continue; // never restore the same session twice seen.add(cs.sessionId); // Forum topics only restore sessions for the fixed project path. if (this.fixedCwd && normPath(cs.projectPath) !== normPath(this.fixedCwd)) continue; this.runtimes.push(this.create({ cwd: cs.projectPath, projectName: cs.projectName, sessionId: cs.sessionId })); } // Lazy project switches persist projectPath without a sessionId. If the // saved project is not among controlled sessions, recreate an unbound FG // so a restart lands on the project the user last chose. const homePath = this.fixedCwd ?? s.projectPath; const homeName = this.fixedProjectName ?? s.projectName; if (homePath) { const key = normPath(homePath); const hasProject = this.runtimes.some((r) => normPath(r.cwd) === key); if (!hasProject) { this.runtimes.push(this.create({ cwd: homePath, projectName: homeName, sessionId: s.sessionId })); } } if (this.runtimes.length > 0) { let fg = this.runtimes.find((r) => r.sessionId && r.sessionId === s.foregroundSessionId); if (!fg && homePath) { const key = normPath(homePath); fg = this.runtimes.find((r) => normPath(r.cwd) === key); } fg = fg ?? this.runtimes[0]!; for (const r of this.runtimes) void r.setForeground(r === fg); this.fg = fg; } } /** Drop any runtime that duplicates another's sessionId (keeping the * foreground one), healing a state where two runtimes wrap one session. */ private pruneDuplicates(): void { const byId = new Map(); const kept: SessionRuntime[] = []; for (const rt of this.runtimes) { const id = rt.sessionId; if (!id) { kept.push(rt); continue; } const prev = byId.get(id); if (!prev) { byId.set(id, rt); kept.push(rt); continue; } const loser = prev.isForeground || !rt.isForeground ? rt : prev; const winner = loser === rt ? prev : rt; if (winner !== prev) { byId.set(id, winner); const i = kept.indexOf(prev); if (i !== -1) kept[i] = winner; } if (this.fg === loser) this.fg = winner; loser.dispose(); } if (kept.length !== this.runtimes.length) { this.runtimes.length = 0; this.runtimes.push(...kept); this.persist(); } } private create(init: { cwd: string; projectName?: string; sessionId?: string }): SessionRuntime { const rt = new SessionRuntime(this.api, this.chatId, this.acp, this.cfg, this.settings, { ...init, messageThreadId: this.messageThreadId, settingsKey: this.settingsKey, }); rt.onStateChange = () => this.refresh(this.chatId); rt.onActivity = (busy) => this.notifyActivity(busy); rt.accountRotator = this.getRotator?.(); rt.bridge = this.bridge; // A logical fork (auto-fork-on-error / lost-session recovery) swaps the // runtime's session id in place — re-persist the controlled list with the // new id and treat the fresh session as already-seen. rt.onSessionChange = () => { this.markSeen(rt); this.persist(); }; rt.onTelegramMessageBound = (messageId, sessionId) => { this.bindTelegramMessage(messageId, sessionId); }; return rt; } private async background(rt: SessionRuntime | undefined): Promise { if (!rt) return; this.markSeen(rt); await rt.setForeground(false); } /** Remove idle runtimes that never bound an ACP session (lazy project taps). */ private pruneUnusedPlaceholders(keep: SessionRuntime): void { for (let i = this.runtimes.length - 1; i >= 0; i--) { const r = this.runtimes[i]!; if (r === keep || r === this.fg) continue; if (r.sessionId || r.isBusy) continue; r.dispose(); this.runtimes.splice(i, 1); } } private markSeen(rt: SessionRuntime): void { if (rt.sessionId) this.lastRead.set(rt.sessionId, jsonlSize(this.store.jsonlPath(rt.sessionId))); } private unreadCount(rt: SessionRuntime): number { if (!rt.sessionId || rt.isForeground) return 0; const seen = this.lastRead.get(rt.sessionId); if (seen === undefined) return 0; return readEntriesFrom(this.store.jsonlPath(rt.sessionId), seen).entries.length; } private persist(): void { const seen = new Set(); const controlled: { sessionId?: string; projectPath: string; projectName?: string }[] = []; for (const r of this.runtimes) { if (!r.sessionId || seen.has(r.sessionId)) continue; seen.add(r.sessionId); controlled.push({ sessionId: r.sessionId, projectPath: r.cwd, projectName: r.projectName }); } this.settings.updateKey(this.settingsKey, { controlledSessions: controlled, foregroundSessionId: this.fg?.sessionId, // Keep the single-session restore fields aligned with the foreground so // the pinned status panel and a fresh restore never show a project that // belongs to a different (previously-foreground) session. sessionId: this.fg?.sessionId, projectPath: this.fixedCwd ?? this.fg?.cwd, projectName: this.fixedProjectName ?? this.fg?.projectName, }); } } /** Path key for project matching (case / separators / trailing slash). */ function normPath(p: string): string { return p.replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase(); } /** Extract `#sess_xxxxxxxx` from bot message text/caption. */ function parseSessTag(text: string | undefined): string | undefined { if (!text) return undefined; const m = text.match(/#sess_([a-z0-9]{6,12})/i); return m?.[1]?.toLowerCase(); } /** Match full session UUID (or short form) to a #sess_ tag body. */ function sessionMatchesTag(sessionId: string, tag: string): boolean { const compact = sessionId.replace(/-/g, "").toLowerCase(); const short = sessionId.slice(0, 8).replace(/[^a-z0-9]/gi, "").toLowerCase(); const t = tag.toLowerCase(); return ( short === t || short.startsWith(t) || t.startsWith(short) || compact.startsWith(t) || sessionId.toLowerCase().startsWith(t) ); }