From ec1997fd0e6a49729ab4753776156d28af2f02f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Wed, 12 Aug 2026 01:54:19 +0000 Subject: [PATCH] a chat with no chosen directory runs in the caller's own home MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The default was DATA_PATH//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 --- src/servers/api/chat/chat.ts | 15 ++++++----- src/servers/api/chat/claude-sessions.ts | 25 ++++++++----------- src/servers/api/chat/websocket.ts | 25 ++++++++++++------- src/servers/data-path.ts | 11 +------- src/servers/os-user-claude.ts | 17 ------------- .../src/apps/ChatHistory/ChatDetailPanel.tsx | 2 +- .../src/apps/ChatHistory/PwdSelector.tsx | 8 +++--- .../src/apps/ChatHistory/SessionList.tsx | 2 +- .../src/apps/ChatHistory/chat-routes.ts | 2 +- src/workspaces/state/src/useClaudeSessions.ts | 2 +- 10 files changed, 41 insertions(+), 68 deletions(-) diff --git a/src/servers/api/chat/chat.ts b/src/servers/api/chat/chat.ts index 38b42430..7bcc3aab 100644 --- a/src/servers/api/chat/chat.ts +++ b/src/servers/api/chat/chat.ts @@ -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/ 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) diff --git a/src/servers/api/chat/claude-sessions.ts b/src/servers/api/chat/claude-sessions.ts index 943db39d..0755dffc 100644 --- a/src/servers/api/chat/claude-sessions.ts +++ b/src/servers/api/chat/claude-sessions.ts @@ -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(); if (existsSync(projectsDir)) { diff --git a/src/servers/api/chat/websocket.ts b/src/servers/api/chat/websocket.ts index 1308a8b5..96df010c 100644 --- a/src/servers/api/chat/websocket.ts +++ b/src/servers/api/chat/websocket.ts @@ -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 { 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//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; diff --git a/src/servers/data-path.ts b/src/servers/data-path.ts index e1f6b9b3..b74c2768 100644 --- a/src/servers/data-path.ts +++ b/src/servers/data-path.ts @@ -52,16 +52,7 @@ export const getOwnerHomeDir = (email: string): string => process.env.HOME_DIR ? // Single-sourced here rather than in the script that used to own the list, because there are now two // callers — `scripts/provision-user-dirs.ts` and the owner's create-account handler — and a skeleton // that differs depending on how the account was made is a bug nobody would think to look for. -export const USER_DIRS = [ - 'home', - 'attachments', - 'cache', - 'dashboards', - 'email_accounts', - 'general_chat_sessions', - 'logs', - 'sidecar', -] as const; +export const USER_DIRS = ['home', 'attachments', 'cache', 'dashboards', 'email_accounts', 'logs', 'sidecar'] as const; /** * Create an account's root and its skeleton, closed by default. diff --git a/src/servers/os-user-claude.ts b/src/servers/os-user-claude.ts index 2203672f..b2d681a1 100644 --- a/src/servers/os-user-claude.ts +++ b/src/servers/os-user-claude.ts @@ -123,23 +123,6 @@ export async function provisionClaudeCli(params: { email: string; osUser: string return { ok: true, binPath, wrote: true }; } -/** - * A member's own chat-sessions directory, created as them, inside their home. - * - * The owner's equivalent lives at `DATA_PATH//general_chat_sessions` — a SIBLING of the home, which - * `confineUserTree` deliberately makes the platform's at 0700 because the other siblings are `attachments` - * and `email_accounts`. Correct for those, and fatal as a member's cwd: the first live member turn ran there - * and every Bash call failed, because the account could not enter its own working directory. - * - * So a member's goes inside their home instead. Created through `runAs` because the platform cannot mkdir - * into a 0700 home it does not own — and `mkdir -p` is idempotent, so this is safe to call per turn. - */ -export async function ensureMemberChatCwd(params: { osUser: string; home: string }): Promise { - const dir = join(params.home, 'general_chat_sessions'); - await asMember(params.osUser, ['mkdir', '-p', dir]); - return dir; -} - export type ClaudeLoginState = { /** The binary is present and executable in their home. */ installed: boolean; diff --git a/src/workspaces/officerdev/src/apps/ChatHistory/ChatDetailPanel.tsx b/src/workspaces/officerdev/src/apps/ChatHistory/ChatDetailPanel.tsx index 105a447b..67978aa4 100644 --- a/src/workspaces/officerdev/src/apps/ChatHistory/ChatDetailPanel.tsx +++ b/src/workspaces/officerdev/src/apps/ChatHistory/ChatDetailPanel.tsx @@ -153,7 +153,7 @@ function NewChat(props: NewChatProps) { invalidateClaudeSessions(); }, [invalidateClaudeSessions]); - // context 'chat' tells the backend to run this session from the dedicated general_chat_sessions cwd, so + // context 'chat' tells the backend to run this session from the caller's own home, so // its transcript lands in Claude's own store as an isolated project group (source of truth). const chat = useChat(undefined, locationState?.model, { resumeSummary, diff --git a/src/workspaces/officerdev/src/apps/ChatHistory/PwdSelector.tsx b/src/workspaces/officerdev/src/apps/ChatHistory/PwdSelector.tsx index 4748faa0..3f566300 100644 --- a/src/workspaces/officerdev/src/apps/ChatHistory/PwdSelector.tsx +++ b/src/workspaces/officerdev/src/apps/ChatHistory/PwdSelector.tsx @@ -4,7 +4,7 @@ import { useChatPwds } from 'state/useClaudeSessions'; import { DirPickerModal } from './DirPickerModal'; type PwdSelectorProps = { - value: string | null; // null = the default general_chat_sessions dir + value: string | null; // null = the caller's own home onChange: (cwd: string | null) => void; }; @@ -19,7 +19,7 @@ export const PwdSelector = ({ value, onChange }: PwdSelectorProps) => { const [custom, setCustom] = useState(''); const isDefaultActive = value === null || value === defaultCwd; - const label = isDefaultActive ? 'general_chat_sessions' : basename(value!); + const label = isDefaultActive ? 'home' : basename(value!); const pick = (cwd: string | null) => { onChange(cwd); @@ -58,9 +58,7 @@ export const PwdSelector = ({ value, onChange }: PwdSelectorProps) => { className={`flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs cursor-pointer hover:bg-muted ${selected ? 'text-duck-teal' : 'text-foreground/80'}`} > - - {p.isDefault ? 'Default · general_chat_sessions' : shorten(p.cwd)} - + {p.isDefault ? 'Default · home' : shorten(p.cwd)} {p.sessionCount > 0 && {p.sessionCount}} ); diff --git a/src/workspaces/officerdev/src/apps/ChatHistory/SessionList.tsx b/src/workspaces/officerdev/src/apps/ChatHistory/SessionList.tsx index 8cf84826..69934715 100644 --- a/src/workspaces/officerdev/src/apps/ChatHistory/SessionList.tsx +++ b/src/workspaces/officerdev/src/apps/ChatHistory/SessionList.tsx @@ -39,7 +39,7 @@ export const SessionList = () => { const [selected, setSelected] = useSelectedChatSession(); // A group path when we're on one; otherwise the open session's own directory, so /chat/ shows // that session among its neighbours instead of snapping the list back to the default group. Null = - // the default general_chat_sessions dir. + // the caller's own home. const activeCwd = cwdFromSplat(splat) ?? selected?.cwd ?? null; const { sessions, isLoading, error, refetch, deleteSession, renameSession } = useClaudeSessions(activeCwd); const [editingId, setEditingId] = useState(null); diff --git a/src/workspaces/officerdev/src/apps/ChatHistory/chat-routes.ts b/src/workspaces/officerdev/src/apps/ChatHistory/chat-routes.ts index 528edecd..868ea265 100644 --- a/src/workspaces/officerdev/src/apps/ChatHistory/chat-routes.ts +++ b/src/workspaces/officerdev/src/apps/ChatHistory/chat-routes.ts @@ -20,7 +20,7 @@ export const GROUP_SEGMENT = 'g'; /** Absolute cwd → path suffix. Encoded per segment; React Router decodes it the same way. */ const encodeCwd = (cwd: string): string => cwd.split('/').filter(Boolean).map(encodeURIComponent).join('/'); -/** The list for a group. `null` = the default general_chat_sessions group. */ +/** The list for a group. `null` = the caller's own home. */ export const chatListPath = (cwd: string | null | undefined): string => cwd ? `/chat/${GROUP_SEGMENT}/${encodeCwd(cwd)}` : '/chat'; diff --git a/src/workspaces/state/src/useClaudeSessions.ts b/src/workspaces/state/src/useClaudeSessions.ts index 26a09d3d..55f2daf3 100644 --- a/src/workspaces/state/src/useClaudeSessions.ts +++ b/src/workspaces/state/src/useClaudeSessions.ts @@ -63,7 +63,7 @@ export type ClaudeSessionDetail = { export type ClaudePwd = { cwd: string; sessionCount: number; updatedAt: string; isDefault: boolean }; -// Query string for the selected working directory (null/undefined = the default general_chat_sessions dir). +// Query string for the selected working directory (null/undefined = the caller's own home). const cwdQuery = (cwd?: string | null) => (cwd ? `?cwd=${encodeURIComponent(cwd)}` : ''); /** The default /chat dir plus every directory that already has Claude sessions. */