From 7cd2be9fb958b0f375f005be139f079e6870a7c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Sat, 25 Jul 2026 10:50:48 +0000 Subject: [PATCH] chat: tag OpenCode sessions with officer metadata (cwd) + filter lists by it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OpenCode has no per-session directory (every session runs in the fixed server's cwd), so sessions from every context (/chat pwd, email account, project) all landed in one list. Now each session is tagged on creation with its logical cwd via the free-form session `metadata`: { officer: { cwd } } — API-settable, round-trips on list+detail, never touched by opencode core (confirmed by source dive + live test). - client.createSession(metadata?) sends `metadata`; adds OfficerSessionMeta + officerMeta() helper. - send-opencode tags new sessions with { officer: { cwd } } (the resolved chat cwd). - websocket: pass the full resolved cwd for every context (not just non-/chat). - listOpenCodeSessions(cwd?) filters by metadata.officer.cwd; chat.ts passes the request cwd. Verified live: sessions tagged with distinct cwds list only under their own cwd. Co-Authored-By: Claude Opus 4.8 --- src/servers/api/chat/chat.ts | 2 +- src/servers/api/chat/opencode-sessions.ts | 38 +++++++++++++---------- src/servers/api/chat/opencode/client.ts | 15 ++++++--- src/servers/api/chat/websocket.ts | 9 ++---- src/servers/channels/send-opencode.ts | 4 ++- 5 files changed, 40 insertions(+), 28 deletions(-) diff --git a/src/servers/api/chat/chat.ts b/src/servers/api/chat/chat.ts index 54d4c466..6b6373ae 100644 --- a/src/servers/api/chat/chat.ts +++ b/src/servers/api/chat/chat.ts @@ -40,7 +40,7 @@ chatRouter.get('/sessions', async (ctx) => { const email = ctx.get('user').email; const cwd = cwdOf(ctx, email); const claude = listClaudeSessions(email, cwd).map((s) => ({ ...s, harness: 'claude' as const })); - const opencode = await listOpenCodeSessions(); + const opencode = await listOpenCodeSessions(cwd); const sessions = [...claude, ...opencode].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)); return ctx.json({ sessions }); }); diff --git a/src/servers/api/chat/opencode-sessions.ts b/src/servers/api/chat/opencode-sessions.ts index 8b8e4884..2597f851 100644 --- a/src/servers/api/chat/opencode-sessions.ts +++ b/src/servers/api/chat/opencode-sessions.ts @@ -1,6 +1,6 @@ import type { ClaudeSessionSummary, ClaudeSessionDetail, ClaudeChatMessage } from './claude-sessions'; import { ensureServer } from './opencode/server-manager'; -import { getConnection } from './opencode/client'; +import { getConnection, officerMeta } from './opencode/client'; import { logger } from './logger'; // The OpenCode analog of claude-sessions.ts. OpenCode's own store is the source of truth, read via the @@ -8,24 +8,30 @@ import { logger } from './logger'; // one server's project, so listing is just GET /session. Returns the same shapes as the Claude reader, // tagged harness:'opencode', so chat.ts can merge both harnesses transparently. -/** List OpenCode sessions. Never throws — returns [] if the server is unavailable. */ -export async function listOpenCodeSessions(): Promise { +/** + * List OpenCode sessions tagged with the given logical cwd (via `metadata.officer.cwd`), so each + * context (/chat pwd, email account, project) sees only its own. With no cwd, returns all. Never + * throws — returns [] if the server is unavailable. + */ +export async function listOpenCodeSessions(cwd?: string): Promise { try { const { baseUrl } = await ensureServer(); const sessions = await getConnection(baseUrl).listSessions(); - return sessions.map((s) => { - const created = s.time?.created ?? Date.now(); - const updated = s.time?.updated ?? created; - return { - id: s.id, - title: s.title || '(untitled)', - cwd: s.location?.directory ?? '', - createdAt: new Date(created).toISOString(), - updatedAt: new Date(updated).toISOString(), - messageCount: 0, // the session list endpoint doesn't include a turn count - harness: 'opencode', - } satisfies ClaudeSessionSummary; - }); + return sessions + .filter((s) => !cwd || officerMeta(s.metadata).cwd === cwd) + .map((s) => { + const created = s.time?.created ?? Date.now(); + const updated = s.time?.updated ?? created; + return { + id: s.id, + title: s.title || '(untitled)', + cwd: s.location?.directory ?? '', + createdAt: new Date(created).toISOString(), + updatedAt: new Date(updated).toISOString(), + messageCount: 0, // the session list endpoint doesn't include a turn count + harness: 'opencode', + } satisfies ClaudeSessionSummary; + }); } catch (err) { logger.warn('Failed to list OpenCode sessions', { error: String(err) }); return []; diff --git a/src/servers/api/chat/opencode/client.ts b/src/servers/api/chat/opencode/client.ts index e4a278d1..14e69c82 100644 --- a/src/servers/api/chat/opencode/client.ts +++ b/src/servers/api/chat/opencode/client.ts @@ -105,11 +105,12 @@ class ServerConnection { return (await res.json()) as T; } - async createSession(directory?: string, title?: string): Promise { - // `directory` binds the session's working dir (e.g. an email account dir). Omit it for the general - // /chat, so the session lives in the fixed server's own project (its cwd). + async createSession(metadata?: Record, title?: string): Promise { + // OpenCode has no per-session `directory` (a session inherits the server's cwd). We instead tag + // the session with our own free-form `metadata` (e.g. { officer: { cwd } }) — round-trips on the + // list + detail endpoints and is never touched by opencode core — to know where it belongs. const body: Record = {}; - if (directory) body.directory = directory; + if (metadata) body.metadata = metadata; if (title) body.title = title; const session = await this.postJson<{ id?: string }>('/session', body); if (!session.id) throw new Error('opencode POST /session returned no id'); @@ -162,8 +163,14 @@ export type OpenCodeSessionInfo = { title?: string; time?: { created?: number; updated?: number }; location?: { directory?: string | null }; + metadata?: Record; }; +// Our own namespaced session metadata (stored under the free-form `metadata.officer` key). +export type OfficerSessionMeta = { cwd?: string }; +export const officerMeta = (m?: Record): OfficerSessionMeta => + (m?.officer as OfficerSessionMeta) ?? {}; + export type OpenCodeStoredPart = { type?: string; text?: string; diff --git a/src/servers/api/chat/websocket.ts b/src/servers/api/chat/websocket.ts index 44571102..815dea82 100644 --- a/src/servers/api/chat/websocket.ts +++ b/src/servers/api/chat/websocket.ts @@ -85,7 +85,8 @@ async function resolveChatCwd( userId: number, ): Promise { if (msg.context === 'email') return resolveEmailCwd(userId, email, msg.contextId); - if (msg.context === 'chat') return msg.cwd?.trim() ? resolveCwd(email, role, msg.cwd) : ensureClaudeSessionsCwd(email); + if (msg.context === 'chat') + return msg.cwd?.trim() ? resolveCwd(email, role, msg.cwd) : ensureClaudeSessionsCwd(email); return resolveCwd(email, role, msg.cwd); } @@ -424,10 +425,6 @@ async function handleOpenCodeChat( const cwd = await resolveChatCwd(msg, email, ws.data.role, userId); - // OpenCode's working directory: a context-scoped dir (email account, project, …) for OpenCode to - // operate in; the general /chat runs in the fixed server's default project (home), so pass none. - const openCodeDir = msg.context && msg.context !== 'chat' ? cwd : undefined; - const groupSlug = msg.groupSlug || null; const session = sessionManager.getOrCreate(sessionId, email, cwd, model, groupSlug, msg.context, msg.contextId); @@ -469,7 +466,7 @@ async function handleOpenCodeChat( username, prompt: effectivePrompt, sessionKey: sessionId, - cwd: openCodeDir, + cwd, model, role: ws.data.role, resumeSessionId: msg.resumeSessionId, diff --git a/src/servers/channels/send-opencode.ts b/src/servers/channels/send-opencode.ts index 92946f24..2561739d 100644 --- a/src/servers/channels/send-opencode.ts +++ b/src/servers/channels/send-opencode.ts @@ -45,7 +45,9 @@ export async function sendOpenCodeStreaming(params: OpenCodeStreamingParams): Pr (params.sessionKey.startsWith('ses_') ? params.sessionKey : undefined) ?? params.resumeSessionId; if (!opencodeSessionId) { - opencodeSessionId = await conn.createSession(params.cwd); + // Tag the session with its logical cwd so it can be listed in the right place (OpenCode has no + // per-session directory — every session runs in the fixed server's cwd). + opencodeSessionId = await conn.createSession(params.cwd ? { officer: { cwd: params.cwd } } : undefined); } setOpenCodeSession(params.sessionKey, opencodeSessionId); const sessionId = opencodeSessionId;