Skip to content

feat: per-thread session isolation for Claude and Codex agents#152

Open
jlia0 wants to merge 3 commits intomainfrom
feat/session-id-per-thread
Open

feat: per-thread session isolation for Claude and Codex agents#152
jlia0 wants to merge 3 commits intomainfrom
feat/session-id-per-thread

Conversation

@jlia0
Copy link
Collaborator

@jlia0 jlia0 commented Mar 1, 2026

Summary

  • Adds thread_sessions table to track (agent_id, thread_id) → session_id mappings, enabling independent sessions per user per agent
  • Claude: uses --session-id <uuid> for new sessions and -r <uuid> to resume, replacing the shared -c (continue last) flag
  • Codex: uses codex exec resume <session_id> for existing threads, captures session ID from JSONL output on first run
  • OpenCode: unchanged (no session-id equivalent)
  • Telegram sends threadId: "telegram:<chat_id>", Discord sends threadId: "discord:<user_id>"
  • Falls back to existing -c / resume --last behavior for API calls without thread context
  • On reset, old sessions are preserved in Claude/Codex session storage (users can revisit them) — only the thread mapping is overwritten with the new session

Closes #144

Files changed

  • src/lib/db.ts — new thread_sessions table, getThreadSession/saveThreadSession/deleteThreadSession helpers, thread_id column on messages
  • src/lib/invoke.ts — session-id logic for Claude and Codex providers
  • src/queue-processor.ts — pass threadId to invokeAgent
  • src/server/routes/messages.ts — accept threadId in POST body
  • src/channels/telegram-client.ts — set threadId on messages
  • src/channels/discord-client.ts — set threadId on messages

Test plan

  • npm run build passes
  • Send a message from Telegram → Claude agent invoked with --session-id <uuid>
  • Send another message from same user → agent invoked with -r <uuid> (same session)
  • /reset @agent → next message gets a fresh --session-id <new-uuid>
  • Two different Telegram/Discord users → each gets their own session UUID
  • API calls without threadId still use -c / resume --last behavior
  • Codex agent: first message starts new session, follow-up resumes with captured session ID

🤖 Generated with Claude Code

jlia0 and others added 3 commits March 1, 2026 17:32
Use Claude's --session-id / -r flags to maintain independent sessions
per (agent_id, thread_id) pair, so messages from different users or
channels no longer bleed context into each other.

Closes #144

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Use `codex exec resume <session_id>` for existing threads and capture
session ID from JSONL output on first run. Falls back to `resume --last`
when no threadId is provided.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
On reset, just start a new session and let saveThreadSession overwrite
the mapping. The old session still exists in Claude/Codex session storage
so users can revisit previous conversations.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@jlia0 jlia0 changed the title feat: per-thread session isolation for Claude agents feat: per-thread session isolation for Claude and Codex agents Mar 1, 2026
@jlia0
Copy link
Collaborator Author

jlia0 commented Mar 6, 2026

@greptileai review this PR

@greptile-apps
Copy link

greptile-apps bot commented Mar 6, 2026

Greptile Summary

This PR introduces per-thread session isolation for Claude and Codex agents by adding a thread_sessions table that maps (agent_id, thread_id) pairs to provider session IDs. Telegram and Discord channels now stamp each message with a threadId, allowing each user to maintain a private, persistent conversation session with an agent independent of other users.

Key changes:

  • New thread_sessions SQLite table and helpers (getThreadSession, saveThreadSession, deleteThreadSession) in src/lib/db.ts, with a migration for the new thread_id column on messages.
  • src/lib/invoke.ts updated so Claude uses --session-id <uuid> for new sessions and -r <uuid> to resume; Codex uses codex exec resume <session_id> for returning threads and captures the session ID from JSONL output on first run.
  • Telegram and Discord clients now pass threadId in the queued message payload.
  • Critical bug: In the Codex path, after a /reset, the guard condition !getThreadSession(agentId, threadId) finds the old (pre-reset) DB record and prevents the newly detected session ID from being saved, causing all subsequent messages to incorrectly resume the discarded session. The Claude path correctly avoids this by using the already-computed existing variable.
  • deleteThreadSession is exported but never called anywhere in the codebase.
  • The WhatsApp channel was not updated to pass a threadId, so WhatsApp users do not receive per-user session isolation.

Confidence Score: 2/5

  • Not safe to merge — there is a confirmed logic bug that silently breaks Codex session isolation after any user reset.
  • The Codex reset flow has a reproducible bug: after a /reset, the new Codex session ID is never persisted because !getThreadSession(agentId, threadId) still finds the old record. Every subsequent Codex message from that thread will silently resume the pre-reset session rather than the new one, making the reset feature non-functional for Codex users. The Claude path is correct and is unaffected. The remaining issues (unused export, missing WhatsApp threadId) are lower severity but indicate the feature is incomplete.
  • Pay close attention to src/lib/invoke.ts — specifically the Codex session-save guard condition at line 141.

Important Files Changed

Filename Overview
src/lib/invoke.ts Core session routing logic for Claude and Codex; contains a critical reset bug for Codex where a post-reset session ID is never saved due to an incorrect guard condition (!getThreadSession instead of !existing).
src/lib/db.ts Adds thread_sessions table and CRUD helpers; migration for thread_id column is correct. Minor: deleteThreadSession is exported but never called; created_at is overwritten on session replace.
src/queue-processor.ts Single-line change correctly threads dbMsg.thread_id through to invokeAgent; no issues.
src/server/routes/messages.ts Correctly accepts and forwards threadId from the API request body; no issues.
src/channels/telegram-client.ts Adds threadId: "telegram:<senderId>" to the queued message payload correctly.
src/channels/discord-client.ts Adds threadId: "discord:<author.id>" to the queued message payload correctly; note that the ID is per-user globally (not per guild/channel), which may be intentional.

Sequence Diagram

sequenceDiagram
    participant U as User (Telegram/Discord)
    participant Ch as Channel Client
    participant Q as Queue (SQLite)
    participant QP as Queue Processor
    participant DB as thread_sessions DB
    participant AI as Claude / Codex CLI

    U->>Ch: Send message
    Ch->>Q: enqueueMessage(threadId: "telegram:<id>")
    QP->>Q: claimNextMessage()
    QP->>DB: getThreadSession(agentId, threadId)
    alt Session exists (not reset)
        DB-->>QP: { session_id, provider }
        QP->>AI: claude -r <session_id> -p <msg>
        note over AI: Resumes existing session
    else No session or reset
        DB-->>QP: null
        QP->>AI: claude --session-id <new_uuid> -p <msg>
        QP->>DB: saveThreadSession(agentId, threadId, new_uuid)
        note over AI: Starts new session
    end
    AI-->>QP: response
    QP->>Q: enqueueResponse(...)
    Q-->>Ch: Response delivered to user
Loading

Comments Outside Diff (1)

  1. src/channels/whatsapp-client.ts, line 334-342 (link)

    WhatsApp channel is missing threadId

    Telegram and Discord both now pass threadId: "telegram:<senderId>" / "discord:<userId>" to enable per-user session isolation. The WhatsApp message payload doesn't include a threadId, so WhatsApp users will always fall back to the legacy -c / resume --last behavior — all WhatsApp users share one session per agent rather than getting isolated sessions.

    If session isolation for WhatsApp is desired, add:

    threadId: `whatsapp:${message.from}`,
    

    to the fetch body here.

Last reviewed commit: 7864693

Comment on lines +141 to +144
if (threadId && detectedSessionId && !getThreadSession(agentId, threadId)) {
saveThreadSession(agentId, threadId, detectedSessionId, 'openai');
log('INFO', `Saved Codex session ${detectedSessionId} for thread ${threadId}`);
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Codex reset doesn't save the new session ID

After a reset (shouldReset = true), existing is set to null (line 98) so Codex starts a fresh session. But the old DB record for this (agentId, threadId) pair is still present. When the new detectedSessionId is captured from JSONL output, the guard !getThreadSession(agentId, threadId) calls the DB again and finds the old record — so it returns false and the new session is never saved.

On the very next message from this thread, getThreadSession returns the old (pre-reset) session_id, and Codex wrongly resumes the conversation that was supposed to be discarded.

The Claude path correctly avoids this by using the already-computed existing variable as its guard (else { saveThreadSession(...) }). Codex should do the same:

Suggested change
if (threadId && detectedSessionId && !getThreadSession(agentId, threadId)) {
saveThreadSession(agentId, threadId, detectedSessionId, 'openai');
log('INFO', `Saved Codex session ${detectedSessionId} for thread ${threadId}`);
}
if (threadId && detectedSessionId && !existing) {
saveThreadSession(agentId, threadId, detectedSessionId, 'openai');
log('INFO', `Saved Codex session ${detectedSessionId} for thread ${threadId}`);
}

Comment on lines +390 to +394
export function deleteThreadSession(agentId: string, threadId: string): void {
getDb().prepare(`
DELETE FROM thread_sessions WHERE agent_id = ? AND thread_id = ?
`).run(agentId, threadId);
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

deleteThreadSession is unused dead code

This function is exported but is never called anywhere in the codebase. Per the PR description, resets work by overwriting the mapping via INSERT OR REPLACE (in saveThreadSession), so deletion is not actually needed. Consider removing this export to avoid confusion about whether it's part of the reset flow.

Comment on lines +382 to +388
export function saveThreadSession(agentId: string, threadId: string, sessionId: string, provider: string): void {
const now = Date.now();
getDb().prepare(`
INSERT OR REPLACE INTO thread_sessions (agent_id, thread_id, session_id, provider, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?)
`).run(agentId, threadId, sessionId, provider, now, now);
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

created_at is overwritten on every session update

INSERT OR REPLACE deletes the old row and inserts a fresh one, so created_at is reset to the current time whenever a session is replaced (e.g., after a reset). If you ever want to know when a thread's original session was established, that information is lost.

A common pattern is to preserve created_at during an upsert:

INSERT INTO thread_sessions (agent_id, thread_id, session_id, provider, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(agent_id, thread_id) DO UPDATE SET
    session_id = excluded.session_id,
    provider   = excluded.provider,
    updated_at = excluded.updated_at

This keeps the original created_at while still updating all other fields.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: per-thread session isolation via --session-id / --resume

1 participant