chat: rename claude_sessions → general_chat_sessions; drop dead chat_sessions

The default /chat working directory is used by both the Claude and OpenCode harnesses
now, so its Claude-specific name was misleading.

- Rename the dir + accessors: getClaudeSessionsCwd → getGeneralChatSessionsCwd,
  ensureClaudeSessionsCwd → ensureGeneralChatSessionsCwd, path segment claude_sessions
  → general_chat_sessions (data-path on disk + code + UI labels/comments). No history
  migration — the old Claude transcript slug is orphaned (intentionally).

- Remove the vestigial chat_sessions dir (leftover from the retired session store):
  it only ever held empty claude/archived/ dirs, recreated by a signin hook. Drop that
  hook (+ its dead imports) and the 4 unused data-path accessors (getUserSessionsDir,
  getClaudeDir, getSessionDir, getArchivedSessionDir), and delete the dir.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-25 11:50:48 +00:00
co-authored by Claude Opus 4.8
parent 9a79a76b95
commit 0c3f270419
10 changed files with 107 additions and 60 deletions
-5
View File
@@ -1,9 +1,6 @@
import type { Handler } from 'hono';
import { mkdir } from 'node:fs/promises';
import { join } from 'node:path';
import { getUserByEmail, getPasskeysByUserIdAndOrigin } from 'officerdb';
import { sign } from '@@/jwt';
import { getClaudeDir } from '@@/data-path';
import argon2 from 'argon2';
import * as errors from '@@/custom-errors';
import { isLockdown, noteBlocked } from './panic';
@@ -33,8 +30,6 @@ export const signinHandler: Handler = async function (ctx) {
const { id, name, username, role } = dbUser;
mkdir(join(getClaudeDir(email), 'archived'), { recursive: true }).catch(() => {});
const tokenUser = { id, email, name, username, role, passkeys: passkeys.length };
if (passkeys.length > 0 && !origin.startsWith('chrome-extension://') && !TEST_USERS.includes(dbUser.id)) {
+4 -4
View File
@@ -2,7 +2,7 @@ import type { Context } from 'hono';
import { createRouter } from '../../create-router';
import { getUserSettings } from 'officerdb';
import {
getClaudeSessionsCwd,
getGeneralChatSessionsCwd,
listClaudePwds,
listClaudeSessions,
loadClaudeSession,
@@ -24,14 +24,14 @@ import { transcribeAudio } from '../stt/transcribe';
export const chatRouter = createRouter();
// The working directory a request operates on: an explicit ?cwd= (a chosen pwd), else the default
// claude_sessions dir. Claude groups sessions by cwd, so this selects which project group we read.
// general_chat_sessions dir. Claude groups sessions by cwd, so this selects which project group we read.
// (OpenCode sessions all live in the one fixed server and ignore cwd.)
const cwdOf = (ctx: Context, email: string): string => ctx.req.query('cwd')?.trim() || getClaudeSessionsCwd(email);
const cwdOf = (ctx: Context, email: string): string => ctx.req.query('cwd')?.trim() || getGeneralChatSessionsCwd(email);
// GET /chat/pwds — the default /chat dir plus every directory that already has Claude sessions.
chatRouter.get('/pwds', (ctx) => {
const email = ctx.get('user').email;
return ctx.json({ pwds: listClaudePwds(email), default: getClaudeSessionsCwd(email) });
return ctx.json({ pwds: listClaudePwds(email), default: getGeneralChatSessionsCwd(email) });
});
// GET /chat/sessions[?cwd=] — conversations for a working directory, merged across both harnesses
+40 -9
View File
@@ -1,4 +1,15 @@
import { readdirSync, readFileSync, existsSync, statSync, mkdirSync, rmSync, appendFileSync, openSync, readSync, closeSync } from 'node:fs';
import {
readdirSync,
readFileSync,
existsSync,
statSync,
mkdirSync,
rmSync,
appendFileSync,
openSync,
readSync,
closeSync,
} from 'node:fs';
import { join } from 'node:path';
import { DATA_PATH } from '../../data-path';
@@ -13,11 +24,11 @@ import { DATA_PATH } from '../../data-path';
const claudeHome = (email: string): string => process.env.HOME_DIR ?? join(DATA_PATH, email, 'home');
/** Dedicated working directory for /chat sessions, so they form their own Claude "project" group. */
export const getClaudeSessionsCwd = (email: string): string => join(DATA_PATH, email, 'claude_sessions');
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 ensureClaudeSessionsCwd = (email: string): string => {
const dir = getClaudeSessionsCwd(email);
export const ensureGeneralChatSessionsCwd = (email: string): string => {
const dir = getGeneralChatSessionsCwd(email);
mkdirSync(dir, { recursive: true });
return dir;
};
@@ -44,7 +55,11 @@ function entryText(message: unknown): string {
if (typeof content === 'string') return content;
if (Array.isArray(content)) {
return content
.map((block) => (block && typeof block === 'object' && (block as { type?: string }).type === 'text' ? (block as { text?: string }).text ?? '' : ''))
.map((block) =>
block && typeof block === 'object' && (block as { type?: string }).type === 'text'
? ((block as { text?: string }).text ?? '')
: '',
)
.join('')
.trim();
}
@@ -116,7 +131,14 @@ function summarizeTranscript(filePath: string, id: string): ClaudeSessionSummary
export type ClaudeChatMessage =
| { role: 'user'; text: string }
| { role: 'assistant'; id: string; text: string }
| { role: 'tool'; toolName: string; toolInput: Record<string, unknown>; toolCallId: string; output?: string; isError?: boolean };
| {
role: 'tool';
toolName: string;
toolInput: Record<string, unknown>;
toolCallId: string;
output?: string;
isError?: boolean;
};
type ContentBlock =
| { type: 'text'; text?: string }
@@ -128,7 +150,11 @@ function blockText(content: unknown): string {
if (typeof content === 'string') return content;
if (Array.isArray(content)) {
return content
.map((b) => (b && typeof b === 'object' && (b as { type?: string }).type === 'text' ? (b as { text?: string }).text ?? '' : ''))
.map((b) =>
b && typeof b === 'object' && (b as { type?: string }).type === 'text'
? ((b as { text?: string }).text ?? '')
: '',
)
.join('');
}
return '';
@@ -185,7 +211,12 @@ export function loadClaudeSession(email: string, cwd: string, sessionId: string)
if (block.type === 'text' && block.text?.trim()) {
messages.push({ role: 'assistant', id: `${sessionId}-${messages.length}`, text: block.text });
} else if (block.type === 'tool_use') {
const tool = { role: 'tool' as const, toolName: block.name, toolInput: block.input ?? {}, toolCallId: block.id };
const tool = {
role: 'tool' as const,
toolName: block.name,
toolInput: block.input ?? {},
toolCallId: block.id,
};
messages.push(tool);
toolById.set(block.id, tool);
}
@@ -257,7 +288,7 @@ export type ClaudePwd = { cwd: string; sessionCount: number; updatedAt: string;
/** All working directories that have Claude sessions, plus the default /chat dir. Newest first. */
export function listClaudePwds(email: string): ClaudePwd[] {
const projectsDir = claudeProjectsDir(email);
const defaultCwd = getClaudeSessionsCwd(email);
const defaultCwd = getGeneralChatSessionsCwd(email);
const byCwd = new Map<string, { count: number; updatedAt: string }>();
if (existsSync(projectsDir)) {
+5 -5
View File
@@ -4,7 +4,7 @@ import type { ClientMessage, ServerMessage, Message, ChatEvent } from './types';
import { sessionManager } from './session-manager';
import { sendClaudeCodeStreaming } from '@@/channels/send-claude-code';
import { sendOpenCodeStreaming } from '@@/channels/send-opencode';
import { ensureClaudeSessionsCwd, getClaudeSessionsCwd } from './claude-sessions';
import { ensureGeneralChatSessionsCwd, getGeneralChatSessionsCwd } from './claude-sessions';
import * as sidecar from '@@/sidecar-registry';
import { join } from 'path';
import { getHomeDirForRole, getEmailAccountsDir } from '../../../servers/data-path';
@@ -77,7 +77,7 @@ 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 claude_sessions dir; everything else (browser/project/dashboard) → the given cwd.
// pwd or the default general_chat_sessions dir; everything else (browser/project/dashboard) → the given cwd.
async function resolveChatCwd(
msg: { context?: string; contextId?: string; cwd?: string },
email: string,
@@ -86,7 +86,7 @@ async function resolveChatCwd(
): 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 msg.cwd?.trim() ? resolveCwd(email, role, msg.cwd) : ensureGeneralChatSessionsCwd(email);
return resolveCwd(email, role, msg.cwd);
}
@@ -460,8 +460,8 @@ async function handleOpenCodeChat(
const onEvent = createEventHandler(sessionId, model, cwd);
// The dir the OpenCode model should treat as its cwd (via the Officer system prompt). For the general
// /chat, the resolved cwd is just the claude_sessions grouping placeholder, so use the user's home.
const workingDir = cwd === getClaudeSessionsCwd(email) ? getHomeDirForRole(email, ws.data.role) : cwd;
// /chat, the resolved cwd is just the general_chat_sessions grouping placeholder, so use the user's home.
const workingDir = cwd === getGeneralChatSessionsCwd(email) ? getHomeDirForRole(email, ws.data.role) : cwd;
try {
const handle = await sendOpenCodeStreaming({
+2 -11
View File
@@ -25,16 +25,6 @@ export const AGENT_CONFIG_DIR = join(homedir(), '.pi', 'agent');
export const SEED_PATH = resolve(import.meta.dir, '../../seed');
export const getUserSessionsDir = (email: string) => join(DATA_PATH, email, 'chat_sessions');
export const getClaudeDir = (email: string) => join(DATA_PATH, email, 'chat_sessions', 'claude');
export const getSessionDir = (email: string, sessionId: string) =>
join(DATA_PATH, email, 'chat_sessions', 'claude', sessionId);
export const getArchivedSessionDir = (email: string, sessionId: string) =>
join(DATA_PATH, email, 'chat_sessions', 'claude', 'archived', sessionId);
export const getHomeDir = (email: string) => join(DATA_PATH, email, 'home');
export const getHomeDirForRole = (email: string, role: string | null): string =>
@@ -53,7 +43,8 @@ export const getAttachmentsDir = (email: string, sessionId: string) => join(DATA
export const getEmailAccountsDir = (ownerEmail: string) => join(DATA_PATH, ownerEmail, 'email_accounts');
export const getEmailDbPath = (ownerEmail: string, accountEmail: string) =>
join(getEmailAccountsDir(ownerEmail), accountEmail, 'emails.db');
export const getEmailAttachmentCacheDir = (ownerEmail: string) => join(getEmailAccountsDir(ownerEmail), 'attachment_cache');
export const getEmailAttachmentCacheDir = (ownerEmail: string) =>
join(getEmailAccountsDir(ownerEmail), 'attachment_cache');
/** Derive a valid Linux username from a display username or email. */
export const toShellUsername = (username: string, email: string): string => {
+3 -10
View File
@@ -55,14 +55,7 @@ export async function spawnClaude(params: ClaudeSpawnParams): Promise<ClaudeCode
const existingSession = getClaudeSession(sessionKey);
const claudeArgs = [
CLAUDE_BIN,
'-p',
prompt,
'--dangerously-skip-permissions',
'--output-format',
'json',
];
const claudeArgs = [CLAUDE_BIN, '-p', prompt, '--dangerously-skip-permissions', '--output-format', 'json'];
const isSuperAdmin = params.role === 'Super Admin';
const mcpConfig = isSuperAdmin ? mcpHostPath : mcpSandboxPath;
@@ -76,7 +69,7 @@ export async function spawnClaude(params: ClaudeSpawnParams): Promise<ClaudeCode
}
const spawnCmd = isSuperAdmin ? claudeArgs : [...buildSandboxArgs(email), ...claudeArgs];
// Honor a caller-provided working directory (e.g. /chat runs from a dedicated claude_sessions dir);
// Honor a caller-provided working directory (e.g. /chat runs from a dedicated general_chat_sessions dir);
// fall back to the host home for Super Admin, or the sandbox default otherwise.
const spawnCwd = isSuperAdmin ? (params.cwd ?? HOST_HOME) : undefined;
@@ -179,7 +172,7 @@ export async function spawnClaudeStreaming(
}
const spawnCmd = isSuperAdmin ? claudeArgs : [...buildSandboxArgs(email), ...claudeArgs];
// Honor a caller-provided working directory (e.g. /chat runs from a dedicated claude_sessions dir);
// Honor a caller-provided working directory (e.g. /chat runs from a dedicated general_chat_sessions dir);
// fall back to the host home for Super Admin, or the sandbox default otherwise.
const spawnCwd = isSuperAdmin ? (params.cwd ?? HOST_HOME) : undefined;
@@ -69,12 +69,18 @@ function NewChat({ resumeSummary, resumeSessionId, initialMessages }: NewChatPro
invalidateClaudeSessions();
}, [invalidateClaudeSessions]);
// context 'chat' tells the backend to run this session from the dedicated claude_sessions cwd, so
// context 'chat' tells the backend to run this session from the dedicated general_chat_sessions cwd, so
// its transcript lands in Claude's own store as an isolated project group (source of truth).
const chat = useChat(undefined, locationState?.model, { resumeSummary, resumeSessionId, initialMessages, onTurnComplete, context: 'chat' });
const chat = useChat(undefined, locationState?.model, {
resumeSummary,
resumeSessionId,
initialMessages,
onTurnComplete,
context: 'chat',
});
const sandboxed = !isSuperAdmin;
// Run the session in the pwd chosen in the Sessions panel; null → backend default (claude_sessions).
// Run the session in the pwd chosen in the Sessions panel; null → backend default (general_chat_sessions).
const [activeCwd] = usePanelChannel<string | null>('chat:active-cwd', null);
const cwd = activeCwd ? { path: activeCwd } : locationState?.cwd;
@@ -4,7 +4,7 @@ import { useChatPwds } from 'state/useClaudeSessions';
import { DirPickerModal } from './DirPickerModal';
type PwdSelectorProps = {
value: string | null; // null = the default claude_sessions dir
value: string | null; // null = the default general_chat_sessions dir
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 ? 'claude_sessions' : basename(value!);
const label = isDefaultActive ? 'general_chat_sessions' : basename(value!);
const pick = (cwd: string | null) => {
onChange(cwd);
@@ -35,7 +35,7 @@ export const PwdSelector = ({ value, onChange }: PwdSelectorProps) => {
<div className="relative">
<button
onClick={() => setOpen((o) => !o)}
title={isDefaultActive ? defaultCwd ?? 'Default' : value!}
title={isDefaultActive ? (defaultCwd ?? 'Default') : value!}
className="flex items-center gap-1.5 max-w-[13rem] rounded-md border border-duck-dark/10 dark:border-foreground/10 px-2 py-1 text-xs text-duck-dark/70 dark:text-foreground/70 hover:bg-duck-dark/5 dark:hover:bg-foreground/5 cursor-pointer transition-colors"
>
<FolderOpen className="h-3.5 w-3.5 shrink-0 text-duck-teal/70" />
@@ -58,7 +58,9 @@ 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-duck-dark/5 dark:hover:bg-foreground/5 ${selected ? 'text-duck-teal' : 'text-duck-dark/70 dark:text-foreground/70'}`}
>
<Check className={`h-3.5 w-3.5 shrink-0 ${selected ? 'opacity-100' : 'opacity-0'}`} />
<span className="min-w-0 flex-1 truncate">{p.isDefault ? 'Default · claude_sessions' : shorten(p.cwd)}</span>
<span className="min-w-0 flex-1 truncate">
{p.isDefault ? 'Default · general_chat_sessions' : shorten(p.cwd)}
</span>
{p.sessionCount > 0 && <span className="shrink-0 opacity-40">{p.sessionCount}</span>}
</button>
);
@@ -11,7 +11,7 @@ import { PwdSelector } from './PwdSelector';
// Clicking a session loads its transcript and continues the real Claude session via --resume.
export const SessionList = () => {
const navigate = useNavigate();
// The working directory the list operates on (null = the default claude_sessions dir).
// The working directory the list operates on (null = the default general_chat_sessions dir).
const [activeCwd, setActiveCwd] = usePanelChannel<string | null>('chat:active-cwd', null);
const { sessions, isLoading, refetch, loadSession, deleteSession, renameSession } = useClaudeSessions(activeCwd);
const [selected, setSelected] = usePanelChannel<SelectedSession>('chat:selected-session', null);
@@ -125,10 +125,20 @@ export const SessionList = () => {
onBlur={commitRename}
className="flex-1 min-w-0 bg-transparent border-b border-duck-teal/40 text-sm outline-none"
/>
<button onMouseDown={(ev) => ev.preventDefault()} onClick={commitRename} className="p-1 text-duck-teal hover:opacity-80 cursor-pointer" title="Save">
<button
onMouseDown={(ev) => ev.preventDefault()}
onClick={commitRename}
className="p-1 text-duck-teal hover:opacity-80 cursor-pointer"
title="Save"
>
<Check className="h-3.5 w-3.5" />
</button>
<button onMouseDown={(ev) => ev.preventDefault()} onClick={() => setEditingId(null)} className="p-1 opacity-50 hover:opacity-100 cursor-pointer" title="Cancel">
<button
onMouseDown={(ev) => ev.preventDefault()}
onClick={() => setEditingId(null)}
className="p-1 opacity-50 hover:opacity-100 cursor-pointer"
title="Cancel"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
@@ -145,7 +155,9 @@ export const SessionList = () => {
<MessageSquare className="h-4 w-4 shrink-0 text-duck-teal/60" />
)}
<div className="min-w-0 flex-1">
<div className="text-sm font-medium text-duck-dark/80 dark:text-foreground/80 truncate">{session.title}</div>
<div className="text-sm font-medium text-duck-dark/80 dark:text-foreground/80 truncate">
{session.title}
</div>
<div className="flex items-center gap-2 text-xs text-duck-dark/40 dark:text-foreground/40">
<span>
{new Date(session.updatedAt).toLocaleDateString(undefined, {
@@ -157,7 +169,9 @@ export const SessionList = () => {
</span>
<span>·</span>
{session.harness === 'opencode' ? (
<span className="rounded bg-duck-teal/10 px-1.5 py-0.5 font-medium text-duck-teal">OpenCode</span>
<span className="rounded bg-duck-teal/10 px-1.5 py-0.5 font-medium text-duck-teal">
OpenCode
</span>
) : (
<span>
{session.messageCount} msg{session.messageCount === 1 ? '' : 's'}
@@ -170,10 +184,18 @@ export const SessionList = () => {
{isConfirming ? (
<div className="flex shrink-0 items-center gap-1 mr-2">
<span className="text-xs text-red-500">Delete?</span>
<button onClick={() => handleDelete(session.id)} className="p-1 rounded text-red-500 hover:bg-red-500/10 cursor-pointer" title="Confirm delete">
<button
onClick={() => handleDelete(session.id)}
className="p-1 rounded text-red-500 hover:bg-red-500/10 cursor-pointer"
title="Confirm delete"
>
<Check className="h-3.5 w-3.5" />
</button>
<button onClick={() => setConfirmingId(null)} className="p-1 rounded opacity-50 hover:opacity-100 cursor-pointer" title="Cancel">
<button
onClick={() => setConfirmingId(null)}
className="p-1 rounded opacity-50 hover:opacity-100 cursor-pointer"
title="Cancel"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
@@ -19,13 +19,20 @@ export type ClaudeSessionSummary = {
export type ClaudeSessionMessage =
| { role: 'user'; text: string }
| { role: 'assistant'; id: string; text: string }
| { role: 'tool'; toolName: string; toolInput: Record<string, unknown>; toolCallId: string; output?: string; isError?: boolean };
| {
role: 'tool';
toolName: string;
toolInput: Record<string, unknown>;
toolCallId: string;
output?: string;
isError?: boolean;
};
export type ClaudeSessionDetail = { id: string; model: string; cwd: string; messages: ClaudeSessionMessage[] };
export type ClaudePwd = { cwd: string; sessionCount: number; updatedAt: string; isDefault: boolean };
// Query string for the selected working directory (null/undefined = the default claude_sessions dir).
// Query string for the selected working directory (null/undefined = the default general_chat_sessions dir).
const cwdQuery = (cwd?: string | null) => (cwd ? `?cwd=${encodeURIComponent(cwd)}` : '');
/** The default /chat dir plus every directory that already has Claude sessions. */