Skip to content

Commit 8e0e8ad

Browse files
JohanYPclaude
andcommitted
feat(session): per-channel "current session" so Telegram and WhatsApp don't stomp each other
Until now `settings.json` had a single `currentSession` field that both channels read and wrote. Switching sessions in Telegram silently moved the WhatsApp cursor too (and vice versa), which made the two channels fight over context whenever they were used in parallel. This commit splits the cursor by channel: - `Settings.currentSessionByChannel: { telegram?, whatsapp? }` is the new persisted shape. Each channel keeps its own active session; the OpenCode sessions themselves remain global, so `/sessions` in either channel still lists every session. - `Settings.currentSession` is kept as a deprecated, read-only field for migration. On the first `loadSettings()` after upgrade, any legacy value is moved to `currentSessionByChannel.telegram` (Telegram has been the only channel until V1.x, so the assumption is safe). The legacy field is dropped from disk on the next write. - `setCurrentSession(channel, info)` now takes the channel as a required first arg — TypeScript forces every call site to declare who owns the session, so no future call accidentally reads/writes the wrong slot. - `getCurrentSession(channel?)` defaults to "telegram" so the dozens of read-only call sites in `src/bot/`, `src/attach/`, `src/pinned/`, `src/cron/` keep working unchanged. - `clearSession(channel?)` clears only that channel; with no argument it clears both, used by global resets like `switchToProject`, `/start`, and the session/project mismatch recovery path. Call sites updated: - src/whatsapp/commands/{new,sessions,abort,status}.ts and src/whatsapp/handlers/prompt.ts now pass "whatsapp" - src/bot/commands/{new,sessions,rename,commands}.ts and src/bot/handlers/prompt.ts now pass "telegram" Tests: tests/session/per-channel-session.test.ts covers the new read/write semantics, the migration from legacy single-slot config, and the clearSession variants. 8 new tests; full suite green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent d0ecc09 commit 8e0e8ad

13 files changed

Lines changed: 257 additions & 24 deletions

File tree

src/bot/commands/commands.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -401,7 +401,7 @@ async function ensureSessionForProject(
401401
directory: projectDirectory,
402402
};
403403

404-
setCurrentSession(sessionInfo);
404+
setCurrentSession("telegram", sessionInfo);
405405
await ingestSessionInfoForCache(session);
406406
await ctx.reply(t("bot.session_created", { title: session.title }));
407407

src/bot/commands/new.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ export async function newCommand(ctx: CommandContext<Context>, deps: NewCommandD
5353
title: session.title,
5454
directory: currentProject.worktree,
5555
};
56-
setCurrentSession(sessionInfo);
56+
setCurrentSession("telegram", sessionInfo);
5757
clearAllInteractionState("session_created");
5858
await ingestSessionInfoForCache(session);
5959

src/bot/commands/rename.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,7 @@ export async function handleRenameTextAnswer(ctx: Context): Promise<boolean> {
140140
throw error || new Error("Failed to update session");
141141
}
142142

143-
setCurrentSession({
143+
setCurrentSession("telegram", {
144144
id: sessionInfo.sessionId,
145145
title: newTitle,
146146
directory: sessionInfo.directory,

src/bot/commands/sessions.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -257,7 +257,7 @@ export async function handleSessionSelect(ctx: Context, deps: SessionSelectDeps)
257257
title: session.title,
258258
directory: currentProject.worktree,
259259
};
260-
setCurrentSession(sessionInfo);
260+
setCurrentSession("telegram", sessionInfo);
261261
clearAllInteractionState("session_switched");
262262

263263
await ctx.answerCallbackQuery();

src/bot/handlers/prompt.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -173,7 +173,7 @@ export async function processUserPrompt(
173173
directory: currentProject.worktree,
174174
};
175175

176-
setCurrentSession(currentSession);
176+
setCurrentSession("telegram", currentSession);
177177
await ingestSessionInfoForCache(session);
178178
createdNewSession = true;
179179
} else {

src/session/manager.ts

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,17 +4,33 @@ import {
44
clearSession as clearSettingsSession,
55
SessionInfo,
66
} from "../settings/manager.js";
7+
import type { Channel } from "../messenger/channel.js";
78

89
export type { SessionInfo };
910

10-
export function setCurrentSession(sessionInfo: SessionInfo): void {
11-
setSettingsSession(sessionInfo);
11+
/**
12+
* Set the active session for a specific channel. Both arguments are
13+
* required so call sites are forced to declare which surface owns the
14+
* session — that's what keeps Telegram and WhatsApp from accidentally
15+
* stomping on each other.
16+
*/
17+
export function setCurrentSession(channel: Channel, sessionInfo: SessionInfo): void {
18+
setSettingsSession(channel, sessionInfo);
1219
}
1320

14-
export function getCurrentSession(): SessionInfo | null {
15-
return getSettingsSession() ?? null;
21+
/**
22+
* Get the active session for a channel. Defaults to "telegram" when no
23+
* channel is provided so legacy call sites in src/bot/* keep their
24+
* historical semantics. WhatsApp call sites must pass `"whatsapp"`.
25+
*/
26+
export function getCurrentSession(channel: Channel = "telegram"): SessionInfo | null {
27+
return getSettingsSession(channel) ?? null;
1628
}
1729

18-
export function clearSession(): void {
19-
clearSettingsSession();
30+
/**
31+
* Clear the active session for a channel. Pass no argument to clear ALL
32+
* channels (used by global resets like project switch).
33+
*/
34+
export function clearSession(channel?: Channel): void {
35+
clearSettingsSession(channel);
2036
}

src/settings/manager.ts

Lines changed: 75 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,11 @@ import { cloneScheduledTask, type ScheduledTask } from "../scheduled-task/types.
33
import type { TtsProvider } from "../config.js";
44
import path from "node:path";
55
import { getRuntimePaths } from "../runtime/paths.js";
6+
import type { Channel } from "../messenger/channel.js";
67
import { logger } from "../utils/logger.js";
78

9+
const DEFAULT_CHANNEL: Channel = "telegram";
10+
811
export interface ProjectInfo {
912
id: string;
1013
worktree: string;
@@ -65,7 +68,24 @@ export interface UiPreferences {
6568

6669
export interface Settings {
6770
currentProject?: ProjectInfo;
71+
/**
72+
* @deprecated Single-slot session field kept ONLY for backwards
73+
* compatibility with installs that pre-date per-channel sessions.
74+
* On load it is migrated into `currentSessionByChannel.telegram` and
75+
* deleted from disk on the next write. New code must read/write
76+
* sessions through `currentSessionByChannel`.
77+
*/
6878
currentSession?: SessionInfo;
79+
/**
80+
* Per-channel "currently active" sessions. Each channel keeps its own
81+
* cursor so opening a session in WhatsApp doesn't pull Telegram into
82+
* it (and vice versa). The OpenCode sessions themselves are global —
83+
* this map only tracks which one is active where.
84+
*/
85+
currentSessionByChannel?: {
86+
telegram?: SessionInfo;
87+
whatsapp?: SessionInfo;
88+
};
6989
currentAgent?: string;
7090
currentModel?: ModelInfo;
7191
pinnedMessageId?: number;
@@ -133,17 +153,42 @@ export function clearProject(): void {
133153
void writeSettingsFile(currentSettings);
134154
}
135155

136-
export function getCurrentSession(): SessionInfo | undefined {
137-
return currentSettings.currentSession;
138-
}
139-
140-
export function setCurrentSession(sessionInfo: SessionInfo): void {
141-
currentSettings.currentSession = sessionInfo;
156+
/**
157+
* Read the active session for a channel. Defaults to "telegram" so older
158+
* call sites that never specified a channel keep behaving like before
159+
* (Telegram has been the only channel until V1.x). New WhatsApp code
160+
* MUST pass `"whatsapp"` explicitly — `grep "getCurrentSession()" src/whatsapp/`
161+
* should be zero after migration.
162+
*/
163+
export function getCurrentSession(channel: Channel = DEFAULT_CHANNEL): SessionInfo | undefined {
164+
return currentSettings.currentSessionByChannel?.[channel];
165+
}
166+
167+
/**
168+
* Write the active session for a channel. The channel is REQUIRED to
169+
* force every call site to declare which surface owns this session,
170+
* preventing the easy bug of accidentally cross-pollinating channels.
171+
*/
172+
export function setCurrentSession(channel: Channel, sessionInfo: SessionInfo): void {
173+
if (!currentSettings.currentSessionByChannel) {
174+
currentSettings.currentSessionByChannel = {};
175+
}
176+
currentSettings.currentSessionByChannel[channel] = sessionInfo;
142177
void writeSettingsFile(currentSettings);
143178
}
144179

145-
export function clearSession(): void {
146-
currentSettings.currentSession = undefined;
180+
/**
181+
* Clear the active session.
182+
* - With a channel: clears only that channel's cursor.
183+
* - Without arguments: clears ALL channels (used by reset / project switch).
184+
*/
185+
export function clearSession(channel?: Channel): void {
186+
if (!currentSettings.currentSessionByChannel) return;
187+
if (channel) {
188+
delete currentSettings.currentSessionByChannel[channel];
189+
} else {
190+
currentSettings.currentSessionByChannel = {};
191+
}
147192
void writeSettingsFile(currentSettings);
148193
}
149194

@@ -270,6 +315,28 @@ export async function loadSettings(): Promise<void> {
270315
currentSettings = loadedSettings;
271316
currentSettings.scheduledTasks = cloneScheduledTasks(loadedSettings.scheduledTasks) ?? [];
272317

318+
// Migration: pre-V1.x installs only had a single `currentSession` field,
319+
// shared across whichever channel ran the bot. With per-channel sessions
320+
// we move that value into `currentSessionByChannel.telegram` (Telegram
321+
// was the only channel until V1.x, so the assumption is safe). The old
322+
// field is dropped from the on-disk file on the next write.
323+
if (currentSettings.currentSession && !currentSettings.currentSessionByChannel) {
324+
currentSettings.currentSessionByChannel = {
325+
telegram: currentSettings.currentSession,
326+
};
327+
delete currentSettings.currentSession;
328+
requiresRewrite = true;
329+
logger.info(
330+
"[SettingsManager] Migrated single-slot currentSession into " +
331+
"currentSessionByChannel.telegram",
332+
);
333+
} else if (currentSettings.currentSession) {
334+
// Both fields exist — the new one wins. Drop the legacy field so it
335+
// doesn't pollute future writes.
336+
delete currentSettings.currentSession;
337+
requiresRewrite = true;
338+
}
339+
273340
if (requiresRewrite) {
274341
void writeSettingsFile(currentSettings);
275342
}

src/whatsapp/commands/abort.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ export const abortCommand: WhatsAppCommandHandler = async (ctx) => {
1515
await cancelPendingMenu(ctx.jid);
1616
clearAllInteractionState("whatsapp_abort_command");
1717

18-
const session = getCurrentSession();
18+
const session = getCurrentSession("whatsapp");
1919
if (!session) {
2020
await ctx.reply("No active session.");
2121
return;

src/whatsapp/commands/new.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ export const newCommand: WhatsAppCommandHandler = async (ctx) => {
3434
title: session.title,
3535
directory: project.worktree,
3636
};
37-
setCurrentSession(info);
37+
setCurrentSession("whatsapp", info);
3838
clearAllInteractionState("whatsapp_session_created");
3939
await ingestSessionInfoForCache(session);
4040

src/whatsapp/commands/sessions.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ export const sessionsCommand: WhatsAppCommandHandler = async (ctx) => {
7474
title: session.title,
7575
directory: project.worktree,
7676
};
77-
setCurrentSession(info);
77+
setCurrentSession("whatsapp", info);
7878
clearAllInteractionState("whatsapp_session_switched");
7979

8080
logger.info(

0 commit comments

Comments
 (0)