chat: run the /email chat from the email account's storage dir

The email chat's working directory now resolves to
DATA_PATH/<owner>/email_accounts/<accountEmail> (created if missing), so the agent
operates in the selected account's dir (emails.db, attachment_cache, …).

- websocket.ts: new resolveChatCwd — context 'email' → the account dir (via a new
  resolveEmailCwd), 'chat' → the pwd/claude_sessions dir, else the given cwd. Both the
  Claude and OpenCode handlers use it. The account defaults to the owner's first enabled
  account for now; the account selector will pass it as contextId later.
- OpenCode honors the cwd again: send-opencode passes it as the session `directory`
  (client.createSession(directory?)) for context-scoped chats; the general /chat still
  omits it and uses the fixed server's default project. Verified against the live server
  that directory-bound sessions create + list.

No frontend change — the /email panel already sends context:'email'.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-24 17:37:48 +00:00
co-authored by Claude Opus 4.8
parent 7a60f2cb0e
commit cbe83f39a9
3 changed files with 50 additions and 23 deletions
+7 -3
View File
@@ -105,9 +105,13 @@ class ServerConnection {
return (await res.json()) as T;
}
async createSession(title?: string): Promise<string> {
// No `directory` the session lives in the fixed server's own project (its cwd).
const session = await this.postJson<{ id?: string }>('/session', title ? { title } : {});
async createSession(directory?: string, title?: string): Promise<string> {
// `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).
const body: Record<string, unknown> = {};
if (directory) body.directory = directory;
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');
return session.id;
}
+42 -19
View File
@@ -7,8 +7,9 @@ import { sendOpenCodeStreaming } from '@@/channels/send-opencode';
import { ensureClaudeSessionsCwd } from './claude-sessions';
import * as sidecar from '@@/sidecar-registry';
import { join } from 'path';
import { getHomeDirForRole } from '../../../servers/data-path';
import { getUserSettings } from 'officerdb';
import { getHomeDirForRole, getEmailAccountsDir } from '../../../servers/data-path';
import { getUserSettings, getEmailAccounts } from 'officerdb';
import { mkdirSync } from 'node:fs';
import { logger } from './logger';
// Default model when no user preference is set
@@ -56,6 +57,38 @@ export const resolveBaseCwd = (email: string, role: string, cwd?: string) => {
return resolveCwd(email, role, cwd);
};
// The email chat runs from the selected account's storage dir:
// DATA_PATH/<owner>/email_accounts/<accountEmail>
// `accountEmail` will come from the account selector (msg.contextId) later; for now default to the
// owner's first enabled account. Falls back to the email_accounts root if there are no accounts.
async function resolveEmailCwd(userId: number, ownerEmail: string, accountEmail?: string): Promise<string> {
let account = accountEmail?.trim();
if (!account) {
try {
const accounts = await getEmailAccounts(userId);
account = (accounts.find((a) => a.enabled) ?? accounts[0])?.email;
} catch (err) {
logger.error('Failed to resolve email account for chat cwd', { userId, error: String(err) });
}
}
const dir = account ? join(getEmailAccountsDir(ownerEmail), account) : getEmailAccountsDir(ownerEmail);
mkdirSync(dir, { recursive: true });
return dir;
}
// The working directory a chat turn runs in, by context: email → the account dir; /chat → a chosen
// pwd or the default claude_sessions dir; everything else (browser/project/dashboard) → the given cwd.
async function resolveChatCwd(
msg: { context?: string; contextId?: string; cwd?: string },
email: string,
role: string,
userId: number,
): Promise<string> {
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);
return resolveCwd(email, role, msg.cwd);
}
const wsToSessionMap = new WeakMap<any, string>();
// Per-connection heartbeat. Bun closes a WS idle for `idleTimeout` (60s), and its timer only resets
@@ -309,15 +342,7 @@ async function handleClaudeCodeChat(
): Promise<void> {
const { email, username, userId } = ws.data;
// The standalone /chat route runs from a chosen working directory (the pwd selector) or, by default,
// a dedicated `claude_sessions` dir — so transcripts form their own Claude "project" group per cwd.
// Other contexts (email/project panels) keep their own cwd.
const cwd =
msg.context === 'chat'
? msg.cwd?.trim()
? resolveCwd(email, ws.data.role, msg.cwd)
: ensureClaudeSessionsCwd(email)
: resolveCwd(email, ws.data.role, msg.cwd);
const cwd = await resolveChatCwd(msg, email, ws.data.role, userId);
const groupSlug = msg.groupSlug || null;
@@ -397,13 +422,11 @@ async function handleOpenCodeChat(
): Promise<void> {
const { email, username, userId } = ws.data;
// Same cwd resolution as the Claude path: /chat runs from a chosen pwd or the default dir.
const cwd =
msg.context === 'chat'
? msg.cwd?.trim()
? resolveCwd(email, ws.data.role, msg.cwd)
: ensureClaudeSessionsCwd(email)
: resolveCwd(email, ws.data.role, msg.cwd);
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;
@@ -446,7 +469,7 @@ async function handleOpenCodeChat(
username,
prompt: effectivePrompt,
sessionKey: sessionId,
cwd,
cwd: openCodeDir,
model,
role: ws.data.role,
resumeSessionId: msg.resumeSessionId,
+1 -1
View File
@@ -45,7 +45,7 @@ export async function sendOpenCodeStreaming(params: OpenCodeStreamingParams): Pr
(params.sessionKey.startsWith('ses_') ? params.sessionKey : undefined) ??
params.resumeSessionId;
if (!opencodeSessionId) {
opencodeSessionId = await conn.createSession();
opencodeSessionId = await conn.createSession(params.cwd);
}
setOpenCodeSession(params.sessionKey, opencodeSessionId);
const sessionId = opencodeSessionId;