chat: tag OpenCode sessions with officer metadata (cwd) + filter lists by it
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 <noreply@anthropic.com>
This commit is contained in:
@@ -40,7 +40,7 @@ chatRouter.get('/sessions', async (ctx) => {
|
|||||||
const email = ctx.get('user').email;
|
const email = ctx.get('user').email;
|
||||||
const cwd = cwdOf(ctx, email);
|
const cwd = cwdOf(ctx, email);
|
||||||
const claude = listClaudeSessions(email, cwd).map((s) => ({ ...s, harness: 'claude' as const }));
|
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));
|
const sessions = [...claude, ...opencode].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
||||||
return ctx.json({ sessions });
|
return ctx.json({ sessions });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { ClaudeSessionSummary, ClaudeSessionDetail, ClaudeChatMessage } from './claude-sessions';
|
import type { ClaudeSessionSummary, ClaudeSessionDetail, ClaudeChatMessage } from './claude-sessions';
|
||||||
import { ensureServer } from './opencode/server-manager';
|
import { ensureServer } from './opencode/server-manager';
|
||||||
import { getConnection } from './opencode/client';
|
import { getConnection, officerMeta } from './opencode/client';
|
||||||
import { logger } from './logger';
|
import { logger } from './logger';
|
||||||
|
|
||||||
// The OpenCode analog of claude-sessions.ts. OpenCode's own store is the source of truth, read via the
|
// 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,
|
// 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.
|
// 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<ClaudeSessionSummary[]> {
|
* 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<ClaudeSessionSummary[]> {
|
||||||
try {
|
try {
|
||||||
const { baseUrl } = await ensureServer();
|
const { baseUrl } = await ensureServer();
|
||||||
const sessions = await getConnection(baseUrl).listSessions();
|
const sessions = await getConnection(baseUrl).listSessions();
|
||||||
return sessions.map((s) => {
|
return sessions
|
||||||
const created = s.time?.created ?? Date.now();
|
.filter((s) => !cwd || officerMeta(s.metadata).cwd === cwd)
|
||||||
const updated = s.time?.updated ?? created;
|
.map((s) => {
|
||||||
return {
|
const created = s.time?.created ?? Date.now();
|
||||||
id: s.id,
|
const updated = s.time?.updated ?? created;
|
||||||
title: s.title || '(untitled)',
|
return {
|
||||||
cwd: s.location?.directory ?? '',
|
id: s.id,
|
||||||
createdAt: new Date(created).toISOString(),
|
title: s.title || '(untitled)',
|
||||||
updatedAt: new Date(updated).toISOString(),
|
cwd: s.location?.directory ?? '',
|
||||||
messageCount: 0, // the session list endpoint doesn't include a turn count
|
createdAt: new Date(created).toISOString(),
|
||||||
harness: 'opencode',
|
updatedAt: new Date(updated).toISOString(),
|
||||||
} satisfies ClaudeSessionSummary;
|
messageCount: 0, // the session list endpoint doesn't include a turn count
|
||||||
});
|
harness: 'opencode',
|
||||||
|
} satisfies ClaudeSessionSummary;
|
||||||
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.warn('Failed to list OpenCode sessions', { error: String(err) });
|
logger.warn('Failed to list OpenCode sessions', { error: String(err) });
|
||||||
return [];
|
return [];
|
||||||
|
|||||||
@@ -105,11 +105,12 @@ class ServerConnection {
|
|||||||
return (await res.json()) as T;
|
return (await res.json()) as T;
|
||||||
}
|
}
|
||||||
|
|
||||||
async createSession(directory?: string, title?: string): Promise<string> {
|
async createSession(metadata?: Record<string, unknown>, title?: string): Promise<string> {
|
||||||
// `directory` binds the session's working dir (e.g. an email account dir). Omit it for the general
|
// OpenCode has no per-session `directory` (a session inherits the server's cwd). We instead tag
|
||||||
// /chat, so the session lives in the fixed server's own project (its cwd).
|
// 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<string, unknown> = {};
|
const body: Record<string, unknown> = {};
|
||||||
if (directory) body.directory = directory;
|
if (metadata) body.metadata = metadata;
|
||||||
if (title) body.title = title;
|
if (title) body.title = title;
|
||||||
const session = await this.postJson<{ id?: string }>('/session', body);
|
const session = await this.postJson<{ id?: string }>('/session', body);
|
||||||
if (!session.id) throw new Error('opencode POST /session returned no id');
|
if (!session.id) throw new Error('opencode POST /session returned no id');
|
||||||
@@ -162,8 +163,14 @@ export type OpenCodeSessionInfo = {
|
|||||||
title?: string;
|
title?: string;
|
||||||
time?: { created?: number; updated?: number };
|
time?: { created?: number; updated?: number };
|
||||||
location?: { directory?: string | null };
|
location?: { directory?: string | null };
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Our own namespaced session metadata (stored under the free-form `metadata.officer` key).
|
||||||
|
export type OfficerSessionMeta = { cwd?: string };
|
||||||
|
export const officerMeta = (m?: Record<string, unknown>): OfficerSessionMeta =>
|
||||||
|
(m?.officer as OfficerSessionMeta) ?? {};
|
||||||
|
|
||||||
export type OpenCodeStoredPart = {
|
export type OpenCodeStoredPart = {
|
||||||
type?: string;
|
type?: string;
|
||||||
text?: string;
|
text?: string;
|
||||||
|
|||||||
@@ -85,7 +85,8 @@ async function resolveChatCwd(
|
|||||||
userId: number,
|
userId: number,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
if (msg.context === 'email') return resolveEmailCwd(userId, email, msg.contextId);
|
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);
|
return resolveCwd(email, role, msg.cwd);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -424,10 +425,6 @@ async function handleOpenCodeChat(
|
|||||||
|
|
||||||
const cwd = await resolveChatCwd(msg, email, ws.data.role, userId);
|
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 groupSlug = msg.groupSlug || null;
|
||||||
|
|
||||||
const session = sessionManager.getOrCreate(sessionId, email, cwd, model, groupSlug, msg.context, msg.contextId);
|
const session = sessionManager.getOrCreate(sessionId, email, cwd, model, groupSlug, msg.context, msg.contextId);
|
||||||
@@ -469,7 +466,7 @@ async function handleOpenCodeChat(
|
|||||||
username,
|
username,
|
||||||
prompt: effectivePrompt,
|
prompt: effectivePrompt,
|
||||||
sessionKey: sessionId,
|
sessionKey: sessionId,
|
||||||
cwd: openCodeDir,
|
cwd,
|
||||||
model,
|
model,
|
||||||
role: ws.data.role,
|
role: ws.data.role,
|
||||||
resumeSessionId: msg.resumeSessionId,
|
resumeSessionId: msg.resumeSessionId,
|
||||||
|
|||||||
@@ -45,7 +45,9 @@ export async function sendOpenCodeStreaming(params: OpenCodeStreamingParams): Pr
|
|||||||
(params.sessionKey.startsWith('ses_') ? params.sessionKey : undefined) ??
|
(params.sessionKey.startsWith('ses_') ? params.sessionKey : undefined) ??
|
||||||
params.resumeSessionId;
|
params.resumeSessionId;
|
||||||
if (!opencodeSessionId) {
|
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);
|
setOpenCodeSession(params.sessionKey, opencodeSessionId);
|
||||||
const sessionId = opencodeSessionId;
|
const sessionId = opencodeSessionId;
|
||||||
|
|||||||
Reference in New Issue
Block a user