Summary
Implement a file-backed session store that maps channel conversations to CLI runtime session IDs, enabling session continuity across messaging channels. This is consumed by ChannelBridge (#32) to resume agent sessions via the --resume / --session flags.
File: src/middleware/session-map.ts
Test: src/middleware/session-map.test.ts
Depends on: types module (PR #4, merged)
Purpose
When a user sends a message on Telegram/Discord/Slack/etc., ChannelBridge needs to determine whether this is a new conversation or a continuation. SessionMap provides this mapping:
Channel message arrives
→ SessionMap.get(key) // key = channelId:userId:threadId
→ Found & not expired? // Yes → pass sessionId to runtime
// No → runtime creates new session
→ Runtime returns sessionId
→ SessionMap.set(key, sessionId) // persist for future messages
API Surface
/** Composite key for session lookup. */
export type SessionKey = {
channelId: string;
userId: string;
threadId?: string | undefined;
};
/** Stored session entry. */
export type SessionEntry = {
/** CLI runtime session ID (e.g., Claude session UUID, Codex thread ID). */
sessionId: string;
/** Epoch milliseconds of last access (set/get). */
lastAccessMs: number;
};
/**
* File-backed session store mapping channel conversations to CLI runtime session IDs.
*
* Design decisions:
* - No in-memory cache: every get/set/delete reads from and writes to disk
* - Correctness over performance for the expected low-frequency session lookup pattern
* - Lazy TTL eviction: expired entries are invisible on get(), evicted on next set()
* - Atomic writes via write-to-temp + rename pattern
*/
export class SessionMap {
/**
* @param directory - Directory where the session file is stored
* @param ttlMs - Time-to-live in milliseconds (default: 7 days = 604_800_000)
*/
constructor(directory: string, ttlMs?: number);
/** Get session ID for a channel conversation. Returns undefined if not found or expired. */
get(key: SessionKey): Promise<string | undefined>;
/** Store or update a session ID for a channel conversation. Updates lastAccessMs. */
set(key: SessionKey, sessionId: string): Promise<void>;
/** Delete a session entry. No-op if key doesn't exist. */
delete(key: SessionKey): Promise<void>;
}
Implementation Details
Key Composition
Composite key format: {channelId}:{userId}:{threadId ?? "_"}
- Enables per-user, per-thread session continuity
- Underscore
_ sentinel for threadless conversations (e.g., DMs without threading)
- Example:
telegram-12345:user-42:thread-99 or discord-67890:user-7:_
Storage Format
Single JSON file: remoteclaw-sessions.json in the configured directory.
{
"telegram-12345:user-42:thread-99": {
"sessionId": "sess_abc123",
"lastAccessMs": 1740000000000
},
"discord-67890:user-7:_": {
"sessionId": "thread_xyz",
"lastAccessMs": 1740000000000
}
}
TTL Expiration (Lazy Eviction)
- Default TTL: 7 days (604,800,000 ms)
get(): Returns undefined for expired entries (entry exists but lastAccessMs + ttlMs < now)
set(): Evicts ALL expired entries before writing the new/updated entry
- No background reaper: Lazy eviction only — the file may contain expired entries until the next
set() call
Atomic Writes
To prevent corruption from interrupted writes:
- Write to a temporary file in the same directory (e.g.,
remoteclaw-sessions.json.tmp)
- Rename temp file to the final path (atomic on POSIX filesystems)
Graceful Degradation
- Corrupted JSON file:
get() returns undefined, set() starts fresh (empty store)
- Missing directory:
set() creates the directory recursively before writing
- Missing file:
get() returns undefined, set() creates the file
Disk I/O Pattern
Every operation reads from disk and writes to disk (for set/delete). No in-memory cache. This prioritizes correctness and simplicity over performance, which is appropriate for the expected low-frequency session lookup pattern (one lookup per incoming channel message).
Test Requirements
Test file: src/middleware/session-map.test.ts
Use real temp directories (fs.mkdtempSync) for all tests, cleaned up in afterEach.
CRUD Operations
get() returns undefined for unknown key
set() + get() round-trip: store and retrieve a session ID
- Thread isolation: same
channelId + userId but different threadId → different sessions
delete() removes an entry
delete() is a no-op for missing key (no error thrown)
Persistence
- Data survives across
SessionMap instances using the same directory
(create instance A, set(), create instance B with same dir, get() returns the value)
TTL Expiration
- Entry with old
lastAccessMs (beyond TTL) returns undefined on get()
(use a very short TTL like 1ms and wait, or manipulate lastAccessMs directly)
- Expired entries are evicted on the next
set() call
Resilience
- Corrupted JSON file → graceful recovery:
get() returns undefined, set() can write new data
- Missing directory →
set() creates it and succeeds
Key Composition
- Verify key format:
channelId:userId:threadId
- Verify threadless key:
channelId:userId:_ (when threadId is undefined)
Integration Context
This module is consumed by ChannelBridge (#32):
- On incoming message:
SessionMap.get({ channelId, userId, threadId }) → optional sessionId
- Pass
sessionId to AgentRuntime.execute({ sessionId, ... })
- After execution:
SessionMap.set(key, result.sessionId) to persist the returned session ID
Cron jobs (cron session analysis analysis) may also use SessionMap, with threadId set to the cron job ID for session isolation.
Acceptance Criteria
Summary
Implement a file-backed session store that maps channel conversations to CLI runtime session IDs, enabling session continuity across messaging channels. This is consumed by
ChannelBridge(#32) to resume agent sessions via the--resume/--sessionflags.File:
src/middleware/session-map.tsTest:
src/middleware/session-map.test.tsDepends on: types module (PR #4, merged)
Purpose
When a user sends a message on Telegram/Discord/Slack/etc.,
ChannelBridgeneeds to determine whether this is a new conversation or a continuation.SessionMapprovides this mapping:API Surface
Implementation Details
Key Composition
Composite key format:
{channelId}:{userId}:{threadId ?? "_"}_sentinel for threadless conversations (e.g., DMs without threading)telegram-12345:user-42:thread-99ordiscord-67890:user-7:_Storage Format
Single JSON file:
remoteclaw-sessions.jsonin the configured directory.{ "telegram-12345:user-42:thread-99": { "sessionId": "sess_abc123", "lastAccessMs": 1740000000000 }, "discord-67890:user-7:_": { "sessionId": "thread_xyz", "lastAccessMs": 1740000000000 } }TTL Expiration (Lazy Eviction)
get(): Returnsundefinedfor expired entries (entry exists butlastAccessMs + ttlMs < now)set(): Evicts ALL expired entries before writing the new/updated entryset()callAtomic Writes
To prevent corruption from interrupted writes:
remoteclaw-sessions.json.tmp)Graceful Degradation
get()returnsundefined,set()starts fresh (empty store)set()creates the directory recursively before writingget()returnsundefined,set()creates the fileDisk I/O Pattern
Every operation reads from disk and writes to disk (for
set/delete). No in-memory cache. This prioritizes correctness and simplicity over performance, which is appropriate for the expected low-frequency session lookup pattern (one lookup per incoming channel message).Test Requirements
Test file:
src/middleware/session-map.test.tsUse real temp directories (
fs.mkdtempSync) for all tests, cleaned up inafterEach.CRUD Operations
get()returnsundefinedfor unknown keyset()+get()round-trip: store and retrieve a session IDchannelId+userIdbut differentthreadId→ different sessionsdelete()removes an entrydelete()is a no-op for missing key (no error thrown)Persistence
SessionMapinstances using the same directory(create instance A,
set(), create instance B with same dir,get()returns the value)TTL Expiration
lastAccessMs(beyond TTL) returnsundefinedonget()(use a very short TTL like 1ms and wait, or manipulate
lastAccessMsdirectly)set()callResilience
get()returnsundefined,set()can write new dataset()creates it and succeedsKey Composition
channelId:userId:threadIdchannelId:userId:_(whenthreadIdis undefined)Integration Context
This module is consumed by
ChannelBridge(#32):SessionMap.get({ channelId, userId, threadId })→ optionalsessionIdsessionIdtoAgentRuntime.execute({ sessionId, ... })SessionMap.set(key, result.sessionId)to persist the returned session IDCron jobs (cron session analysis analysis) may also use SessionMap, with
threadIdset to the cron job ID for session isolation.Acceptance Criteria
src/middleware/session-map.tsexportsSessionMapclass,SessionKeytype,SessionEntrytype{channelId}:{userId}:{threadId ?? "_"}get()returnsundefinedfor unknown, expired, or corrupted entriesset()evicts all expired entries, writes atomically (temp + rename)delete()removes entry, no-op for missing keyset()npx vitest run src/middleware/session-map.test.tsnpx vitest run