a chat with no chosen directory runs in the caller's own home

The default was DATA_PATH/<email>/general_chat_sessions, a dedicated directory so /chat
sessions formed their own Claude project group instead of cluttering the home. It is a sibling
of the home, and confineUserTree makes every sibling the platform's at 0700 because the others
are attachments and email_accounts. So it was unreachable for a member: the first live member
turn started there and every Bash call failed on its own working directory before doing
anything.

A per-member copy inside each home fixed the symptom and left two rules to remember. The owner
chose one rule instead — the account's own home, whoever they are — and accepted the trade
knowingly: /chat sessions now share a project group with anything else run from that home,
which was the reason the dedicated directory existed.

Removed rather than left dangling: getGeneralChatSessionsCwd, ensureGeneralChatSessionsCwd,
ensureMemberChatCwd, and general_chat_sessions from USER_DIRS so new accounts stop getting it.
Existing directories are untouched and their transcripts stay where they are — Claude groups by
cwd, so the owner's old /chat history remains under its own project slug rather than moving.

The UI labels move with it: the default group now reads "home" rather than naming a directory
that no longer has a role.

ChatIdentity keeps carrying both email and home. The pairing was justified in the comment by
general_chat_sessions being email-derived, which is now gone — but the distinction it encodes
is real (the email says who, the home says where), so the comment explains that instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-12 01:54:19 +00:00
co-authored by Claude Opus 5
parent 6c84c74c91
commit ec1997fd0e
10 changed files with 41 additions and 68 deletions
+7 -8
View File
@@ -4,7 +4,6 @@ import * as errors from '@@/custom-errors';
import * as sidecar from '@@/sidecar-registry';
import { getUserSettings } from 'officerdb';
import {
getGeneralChatSessionsCwd,
listClaudePwds,
listClaudeSessions,
loadClaudeSession,
@@ -71,22 +70,22 @@ export const chatRouter = createRouter();
// reviewer who had not written it. If you are reverting this, revert to a refusal — not to a narrower one.
// The working directory a request operates on: an explicit ?cwd= (a chosen pwd), else the default
// general_chat_sessions dir. Claude groups sessions by cwd, so this selects which project group we read.
// caller's own home. Claude groups sessions by cwd, so this selects which project group we read.
// OpenCode runs on one fixed serve, but each session records the directory its turn ran in, so cwd
// selects there too.
const cwdOf = (ctx: Context, email: string): string => ctx.req.query('cwd')?.trim() || getGeneralChatSessionsCwd(email);
const cwdOf = (ctx: Context, home: string): string => ctx.req.query('cwd')?.trim() || home;
// GET /chat/pwds — the default /chat dir plus every directory that already has Claude sessions.
chatRouter.get('/pwds', async (ctx) => {
const who = await chatIdentity(ctx.get('user'));
return ctx.json({ pwds: listClaudePwds(who), default: getGeneralChatSessionsCwd(who.email) });
return ctx.json({ pwds: listClaudePwds(who), default: who.home });
});
// GET /chat/sessions[?cwd=] — conversations for a working directory, merged across both harnesses
// (Claude transcripts + OpenCode's session store), newest first.
chatRouter.get('/sessions', async (ctx) => {
const who = await chatIdentity(ctx.get('user'));
const cwd = cwdOf(ctx, who.email);
const cwd = cwdOf(ctx, who.home);
const claude = listClaudeSessions(who, cwd).map((s) => ({ ...s, harness: 'claude' as const }));
const opencode = await listOpenCodeSessions(cwd);
const sessions = [...claude, ...opencode].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
@@ -102,7 +101,7 @@ chatRouter.get('/sessions', async (ctx) => {
chatRouter.get('/sessions/:id', async (ctx) => {
const who = await chatIdentity(ctx.get('user'));
const id = ctx.req.param('id');
const cwd = cwdOf(ctx, who.email);
const cwd = cwdOf(ctx, who.home);
// Fall back to a by-id scan when the (default) cwd doesn't hold it — a fresh /chat/<id> deep-link/refresh
// doesn't know the session's cwd. The returned detail carries the real cwd for the client to scope the UI.
const detail = isOpenCodeSessionId(id)
@@ -212,7 +211,7 @@ chatRouter.get('/live', async (ctx) => {
chatRouter.delete('/sessions/:id', async (ctx) => {
const who = await chatIdentity(ctx.get('user'));
const id = ctx.req.param('id');
const cwd = cwdOf(ctx, who.email);
const cwd = cwdOf(ctx, who.home);
const ok = isOpenCodeSessionId(id) ? await deleteOpenCodeSession(id) : deleteClaudeSession(who, cwd, id);
if (!ok) return ctx.text('Not found', 404);
return ctx.json({ ok: true });
@@ -222,7 +221,7 @@ chatRouter.delete('/sessions/:id', async (ctx) => {
chatRouter.patch('/sessions/:id/title', async (ctx) => {
const who = await chatIdentity(ctx.get('user'));
const id = ctx.req.param('id');
const cwd = cwdOf(ctx, who.email);
const cwd = cwdOf(ctx, who.home);
const { title } = await ctx.req.json<{ title?: string }>();
if (!title?.trim()) return ctx.text('title is required', 400);
const ok = isOpenCodeSessionId(id)
+10 -15
View File
@@ -29,25 +29,20 @@ import { DATA_PATH } from '../../data-path';
// that were a property rather than an assumption. A member reaching these functions would have been handed the
// owner's conversation list.
//
// So the home arrives resolved, from `resolveHomeDir(userId)`, and this file has no way to invent one. The
// email is still passed where a path is genuinely email-derived — `general_chat_sessions` lives under
// DATA_PATH, not under a home — which is why both travel together rather than one standing in for the other.
// So the home arrives resolved, from `resolveHomeDir(userId)`, and this file has no way to invent one.
//
// The email travels alongside it rather than being derived from it, because the two answer different
// questions: the email says WHO, the home says WHERE. They were briefly conflated in the other direction —
// `general_chat_sessions` was an email-derived path under DATA_PATH used as a chat's working directory, and
// because `confineUserTree` makes every sibling of a home the platform's at 0700, a member's turn started in
// a directory it could not enter. That default is now the caller's own home; the pairing survives because the
// distinction it encodes is real.
export type ChatIdentity = {
email: string;
/** From `resolveHomeDir`. Never `getOwnerHomeDir`, which ignores its argument. */
home: string;
};
/** Dedicated working directory for /chat sessions, so they form their own Claude "project" group. */
export const getGeneralChatSessionsCwd = (email: string): string => join(DATA_PATH, email, 'general_chat_sessions');
/** Same, but create the directory if it doesn't exist (call before spawning a /chat session). */
export const ensureGeneralChatSessionsCwd = (email: string): string => {
const dir = getGeneralChatSessionsCwd(email);
mkdirSync(dir, { recursive: true });
return dir;
};
const claudeProjectsDir = (home: string): string => join(home, '.claude', 'projects');
/** Claude's folder name for a working directory. */
@@ -767,10 +762,10 @@ function firstCwd(filePath: string): string {
export type ClaudePwd = { cwd: string; sessionCount: number; updatedAt: string; isDefault: boolean };
/** All working directories that have Claude sessions, plus the default /chat dir. Newest first. */
/** All working directories that have Claude sessions, plus the caller's home. Newest first. */
export function listClaudePwds(who: ChatIdentity): ClaudePwd[] {
const projectsDir = claudeProjectsDir(who.home);
const defaultCwd = getGeneralChatSessionsCwd(who.email);
const defaultCwd = who.home;
const byCwd = new Map<string, { count: number; updatedAt: string }>();
if (existsSync(projectsDir)) {
+16 -9
View File
@@ -13,13 +13,12 @@ import { sessionManager } from './session-manager';
import { rememberOpenCodePrompt } from './opencode/state';
import { sendClaudeCodeStreaming } from '@@/channels/send-claude-code';
import { sendOpenCodeStreaming } from '@@/channels/send-opencode';
import { ensureGeneralChatSessionsCwd } from './claude-sessions';
import * as sidecar from '@@/sidecar-registry';
import { join } from 'path';
import { getOwnerHomeDir, getEmailAccountsDir } from '../../../servers/data-path';
import { getUserSettings, getEmailAccounts, getChatEventsSince, appendChatEvent, getUserById } from 'officerdb';
import { resolveHomeDir } from '@@/user-home';
import { claudeLoginState, ensureMemberChatCwd } from '@@/os-user-claude';
import { claudeLoginState } from '@@/os-user-claude';
import { mkdirSync } from 'node:fs';
import { logger } from './logger';
@@ -132,21 +131,29 @@ async function resolveEmailCwd(userId: number, ownerEmail: string, accountEmail?
}
// The working directory a chat turn runs in, by context: email → the account dir; /chat → a chosen
// pwd or the default general_chat_sessions dir; everything else (browser/project/dashboard) → the given cwd.
// pwd or the caller's own home; everything else (browser/project/dashboard) → the given cwd.
async function resolveChatCwd(
msg: { context?: string; contextId?: string; cwd?: string },
email: string,
userId: number,
home: string,
member?: { osUser: string; home: string },
): Promise<string> {
if (msg.context === 'email') return resolveEmailCwd(userId, email, msg.contextId);
if (msg.context === 'chat') {
if (msg.cwd?.trim()) return resolveCwd(home, msg.cwd);
// The default chat directory. The owner's is a sibling of their home; a member's is INSIDE theirs,
// because that sibling is platform-owned at 0700 by design and a member cannot enter it — which is
// exactly how the first live member turn failed, with every Bash call dying on its own cwd.
return member ? ensureMemberChatCwd(member) : ensureGeneralChatSessionsCwd(email);
// The caller's own home, for everyone.
//
// This used to be `DATA_PATH/<email>/general_chat_sessions`, a dedicated directory so /chat sessions
// formed their own Claude project group and did not clutter the home. That is a sibling of the home,
// and `confineUserTree` makes every sibling the platform's at 0700 because the others are `attachments`
// and `email_accounts` — so it was unreachable for a member. The first live member turn ran there and
// every Bash call failed on its own working directory before doing anything.
//
// A per-member copy inside each home would have worked and would have left two rules to remember. The
// owner chose one: a chat with no chosen directory runs in the account's own home, whoever they are.
// The cost is that /chat sessions now share a project group with anything else run from that home,
// which was the reason the dedicated directory existed and is a trade the owner made knowingly.
return home;
}
return resolveCwd(home, msg.cwd);
}
@@ -402,7 +409,7 @@ async function handleClaudeCodeChat(
const home = identity.kind === 'member' ? identity.run.home : getOwnerHomeDir(email);
const cwd = await resolveChatCwd(msg, email, userId, home, identity.kind === 'member' ? identity.run : undefined);
const cwd = await resolveChatCwd(msg, email, userId, home);
const groupSlug = msg.groupSlug || null;