diff --git a/src/servers/api/chat/claude-sessions.ts b/src/servers/api/chat/claude-sessions.ts index dd8218f4..8ab6975e 100644 --- a/src/servers/api/chat/claude-sessions.ts +++ b/src/servers/api/chat/claude-sessions.ts @@ -1,4 +1,4 @@ -import { dirname, join, resolve } from 'node:path'; +import { basename, dirname, join, resolve } from 'node:path'; import { DATA_PATH, getCommandersDir } from '../../data-path'; import { appendTextAs, @@ -1033,6 +1033,36 @@ export function isCommandersDir(email: string, cwd: string): boolean { } } +/** + * One session's list-shaped summary, found by id wherever it lives. + * + * For callers that hold a set of ids rather than a directory — Command HQ, which reads its commanders from + * `command_hq_sessions` and cannot scan for them any more now that each chooses its own working directory. + * + * Returns null when no transcript can be found, which is the same signal a directory scan gave by omission: + * a filed row whose conversation has been deleted. `purgeCommandHqSessions` is what tidies those. + * + * Deliberately NOT chain-aware in the way `listClaudeSessions` is. It reports the transcript addressed by + * this id; a `/clear` chain's head is what gets filed and what every surface addresses, so the tail is not + * something a caller here holds an id for. + */ +export function loadClaudeSessionSummaryById(who: ChatIdentity, sessionId: string): ClaudeSessionSummary | null { + const filePath = locateTranscript(who, sessionId); + if (!filePath) return null; + + // `mtimeMs` becomes `updatedAt`, which is what every caller sorts on — so it has to be real, not zero. + // Taken from `listTranscriptsAs` over the transcript's OWN directory rather than a bare `statSync`: the + // file may belong to a member, and reading a member's tree needs the as-user path. One directory listing + // per commander, scoped to one group — the same call the old whole-directory scan made, once per row + // instead of once for all of them, which is the cost of letting each choose where it lives. + const group = basename(dirname(filePath)); + const entry = listTranscriptsAs(who.osUser, claudeProjectsDir(who.home), group).find((f) => f.id === sessionId); + if (!entry) return null; + + const summary = summarizeTranscript(who.osUser, filePath, sessionId, entry.mtimeMs); + return summary ? publish(summary) : null; +} + /** * Every conversation in every directory, newest first. * diff --git a/src/servers/api/chat/websocket.ts b/src/servers/api/chat/websocket.ts index 0610e431..2f672ee3 100644 --- a/src/servers/api/chat/websocket.ts +++ b/src/servers/api/chat/websocket.ts @@ -15,12 +15,7 @@ import { sendClaudeCodeStreaming } from '@@/channels/send-claude-code'; import { sendOpenCodeStreaming } from '@@/channels/send-opencode'; import * as sidecar from '@@/sidecar-registry'; import { join } from 'path'; -import { - getOwnerHomeDir, - getEmailAccountsDir, - findEmailAccountName, - getCommandersDir, -} from '../../../servers/data-path'; +import { getOwnerHomeDir, getEmailAccountsDir, findEmailAccountName } from '../../../servers/data-path'; import { attachedFilePaths } from './attached-files'; import { getUserSettings, @@ -155,16 +150,25 @@ async function resolveChatCwd( userId: number, home: string, ): Promise { - // Every commander a user has shares ONE directory — never a client-chosen one, and never per-commander - // (see `getCommandersDir`). `msg.cwd` is ignored on this branch entirely, the same posture the identity - // half of a turn already takes: resolved by the platform, never trusted from the caller. - // - // `mkdirSync` — synchronous, so this returns only once the directory genuinely exists, the same pattern - // `resolveEmailCwd` below already uses. No race: unlike the rejected per-commander design, this directory - // is shared and only the very first commander ever needs the mkdir to do anything; `recursive: true` - // makes every call after that a no-op stat. + /* + A commander runs where the owner chose, and the choice is required. + + This used to force `getCommandersDir(email)` and ignore `msg.cwd` entirely — every commander shared one + empty directory whose only job was to BE a cwd, because the cwd was how a commander was recognised. + Identity is the `command_hq_sessions` row now, so the directory is free to be useful: a commander that + works on the platform can live in the platform checkout, where its relative paths mean something and its + transcripts sit beside the work they are about. + + The chosen path is resolved through `resolveCwd(home, …)`, the same containment the ordinary chat branch + below uses — so "client-chosen" still does not mean "anywhere". A commander cannot be pointed outside + the account's own tree by a crafted message. + + No fallback to the old shared directory when nothing is chosen: the UI requires a directory before the + first message can be sent, and a silent default would be a commander running somewhere nobody picked. + Falling back to the account's home is the honest last resort for a caller that predates the requirement. + */ if (msg.context === 'command-hq') { - const dir = getCommandersDir(email); + const dir = msg.cwd?.trim() ? resolveCwd(home, msg.cwd) : home; mkdirSync(dir, { recursive: true }); return dir; } @@ -479,7 +483,11 @@ function createMessageHandler( if (!user?.email) return; const who = await chatIdentity({ id: userId, email: user.email }).catch(() => null); if (!who) return; - renameClaudeSession(who, getCommandersDir(who.email), filedId, commanderName.trim()); + // `who.home` as the hint, not the old shared commanders directory: a commander chooses its own + // working directory now, so that path is usually wrong. `findTranscript` treats the cwd as a HINT + // and locates the transcript wherever it actually is — so this works either way, and passing a + // path we know is probably wrong would be the kind of thing that reads as intent later. + renameClaudeSession(who, who.home, filedId, commanderName.trim()); }) .catch((err: unknown) => { logger.warn('Could not file this session as a commander', { @@ -896,6 +904,8 @@ export async function startClaudeTurn(params: { images?: PromptImage[]; resumeSessionId?: string; planMode?: boolean; + /** See `commanderName` on the wire type — the name chosen before a commander's first message. */ + commanderName?: string; armWatcher?: boolean; }; effectivePrompt: string; diff --git a/src/servers/api/command-hq/router.ts b/src/servers/api/command-hq/router.ts index 8bb9976b..3e70ee8b 100644 --- a/src/servers/api/command-hq/router.ts +++ b/src/servers/api/command-hq/router.ts @@ -17,6 +17,7 @@ import type { ServerMessage } from '../chat/types'; import { listClaudeSessions, loadClaudeSessionById, + loadClaudeSessionSummaryById, renameClaudeSession, deleteClaudeSession, type ChatIdentity, @@ -62,11 +63,17 @@ import { * * ── Where commanders live ── * - * Every commander shares ONE working directory per user — `getCommandersDir(email)` — never one per - * commander. That means listing a user's commanders is exactly `listClaudeSessions` (the same function - * `/chat` uses for its own per-directory list) pointed at that one directory, filtered to sessions with a - * `command_hq_sessions` row. No new session-scanning code exists here; this router is a client of the same - * transcript reader `/chat` already is. + * **Wherever the owner chose.** Each commander names its own working directory when it is created, and that + * directory is where its relative paths resolve and where its transcripts are grouped. + * + * It was one shared directory per user until 2026-08-22, because the directory WAS the identity — the only + * way to tell a commander from an ordinary chat. Identity is the `command_hq_sessions` row now, so the + * directory became free to be useful, and a commander that works on the platform can live in the platform. + * + * The consequence for this router is that listing is no longer a directory scan. It reads its rows and loads + * each transcript BY ID (`loadClaudeSessionSummaryById`) — a handful of lookups rather than one walk. A row + * whose transcript cannot be found anywhere is treated as absent, exactly as a missing file in the old scan + * was, and `POST /purge` is what tidies those. */ async function commandHqIdentity(user: { id: number; email: string }): Promise { @@ -201,12 +208,24 @@ commandHqRouter.get('/commanders', async (ctx) => { const categoryParam = ctx.req.query('category'); const categoryId = categoryParam === undefined ? undefined : categoryParam === 'none' ? null : Number(categoryParam); - const [rows, live] = await Promise.all([ - listCommandHqSessions(user.id, categoryId), - listClaudeSessions(who, getCommandersDir(who.email)), - ]); + /* + Each commander's transcript is loaded BY ID, not found by scanning one directory. - const liveById = new Map(live.map((s) => [s.id, s])); + It used to be `listClaudeSessions(who, getCommandersDir(...))` — every commander lived in one place, so + one scan found them all and a row without a match there was a row whose transcript had been deleted. A + commander chooses its own working directory now, so that scan finds only the ones that happen to still + live in the old shared folder and would silently hide every other. + + One `locateTranscript` per row instead of one scan for all of them. Commanders are few — this is a + handful of stats, not a directory walk per row — and a row whose transcript cannot be found anywhere is + still treated exactly as before: absent, which is what `purgeCommandHqSessions` exists to tidy. + */ + const rows = await listCommandHqSessions(user.id, categoryId); + const summaries = rows + .map((row) => loadClaudeSessionSummaryById(who, row.sessionId)) + .filter((s): s is NonNullable => !!s); + + const liveById = new Map(summaries.map((s) => [s.id, s])); const filed = rows.filter((row) => liveById.has(row.sessionId)); const [tagsByRow, lastFiredByRow, runningById, costByRow] = await Promise.all([ @@ -272,8 +291,10 @@ commandHqRouter.get('/commanders', async (ctx) => { commandHqRouter.post('/commanders/new', async (ctx) => { const user = ctx.get('user'); const body = await ctx.req - .json<{ prompt?: string; displayText?: string; categoryId?: number | null }>() - .catch(() => ({}) as { prompt?: string; displayText?: string; categoryId?: number | null }); + .json<{ prompt?: string; displayText?: string; categoryId?: number | null; cwd?: string; name?: string }>() + .catch( + () => ({}) as { prompt?: string; displayText?: string; categoryId?: number | null; cwd?: string; name?: string }, + ); const prompt = body.prompt?.trim(); if (!prompt) throw errors.BAD_REQUEST('prompt is required'); @@ -309,7 +330,17 @@ commandHqRouter.post('/commanders/new', async (ctx) => { ws: null, sessionId, model, - msg: { prompt, displayText: body.displayText?.trim() || undefined, context: 'command-hq' }, + msg: { + prompt, + displayText: body.displayText?.trim() || undefined, + context: 'command-hq', + // Where this commander runs, and its name. Both are chosen before the first message — the UI makes + // them mandatory — so this programmatic path takes them too rather than inventing a default. An + // absent cwd falls back to the account's home in `resolveChatCwd`, which is the honest last resort + // for a caller that predates the requirement. + ...(body.cwd?.trim() ? { cwd: body.cwd.trim() } : {}), + ...(body.name?.trim() ? { commanderName: body.name.trim() } : {}), + }, effectivePrompt: prompt, onOutcome: settle, }); @@ -329,7 +360,9 @@ commandHqRouter.post('/commanders/new', async (ctx) => { await createCommandHqSession(user.id, outcome.transcriptId, body.categoryId ?? null); await setCommandHqSessionCategory(user.id, outcome.transcriptId, body.categoryId ?? null); - return ctx.json({ sessionId: outcome.transcriptId, cwd: getCommandersDir(user.email) }); + // The cwd it ACTUALLY ran in, from the spawn outcome — not a guess at the old shared directory, which is + // no longer where a commander necessarily lives. + return ctx.json({ sessionId: outcome.transcriptId, cwd: body.cwd?.trim() ?? null }); }); /** @@ -424,8 +457,10 @@ commandHqRouter.post('/commanders', async (ctx) => { const sessionId = body.sessionId?.trim(); if (!sessionId) throw errors.BAD_REQUEST('sessionId is required'); - const live = listClaudeSessions(who, getCommandersDir(who.email)); - if (!live.some((s) => s.id === sessionId)) throw errors.NOT_FOUND('no such session in the commanders directory'); + // Located by id wherever it lives, not required to be in the old shared directory. A commander chooses + // its own working directory, so "is it in the commanders folder" stopped being a meaningful check — and + // as a GUARD it only ever established that the transcript exists, which this still does. + if (!loadClaudeSessionSummaryById(who, sessionId)) throw errors.NOT_FOUND('no such conversation'); const row = await createCommandHqSession(user.id, sessionId, body.categoryId ?? null); return ctx.json(row); @@ -458,7 +493,9 @@ commandHqRouter.patch('/commanders/:sessionId', async (ctx) => { await setCommandHqSessionCategory(user.id, sessionId, body.categoryId); } if (body.name !== undefined) { - const ok = renameClaudeSession(who, getCommandersDir(who.email), sessionId, body.name.trim()); + // `who.home` as the HINT only — `findTranscript` locates the transcript wherever it actually is, and a + // commander's directory is its own choice now. + const ok = renameClaudeSession(who, who.home, sessionId, body.name.trim()); if (!ok) throw errors.NOT_FOUND('transcript not found'); } if (body.watcherArmed === false) { @@ -522,7 +559,7 @@ commandHqRouter.delete('/commanders/:sessionId', async (ctx) => { // The cwd is a HINT here, not an authority — `deleteClaudeSession` falls back to locating the // transcript wherever it actually is, which is what makes this work for a child that ran in the // platform checkout rather than the commanders directory. - if (deleteClaudeSession(who, getCommandersDir(who.email), child)) childrenDeleted += 1; + if (deleteClaudeSession(who, who.home, child)) childrenDeleted += 1; } catch { // Already gone, or never written. Counted as not-deleted rather than failing the request. } @@ -530,7 +567,7 @@ commandHqRouter.delete('/commanders/:sessionId', async (ctx) => { clearSpawnedRecord(who.email, sessionId); // 3. The commander itself. - deleteClaudeSession(who, getCommandersDir(who.email), sessionId); + deleteClaudeSession(who, who.home, sessionId); await deleteCommandHqSession(user.id, sessionId); // Said back rather than a bare ok: this is the one irreversible action here, and the counts are what @@ -548,8 +585,16 @@ commandHqRouter.delete('/commanders/:sessionId', async (ctx) => { commandHqRouter.post('/purge', async (ctx) => { const user = ctx.get('user'); const who = await commandHqIdentity(user); - const live = listClaudeSessions(who, getCommandersDir(who.email)); - const removed = await purgeCommandHqSessions(user.id, new Set(live.map((s) => s.id))); + /* + "Still exists" is now per row, wherever that row's transcript lives. + + Scanning one directory would have deleted every commander that chose a different one — a housekeeping + route quietly unfiling live commanders is the worst possible version of this bug, because the rows are + what identity depends on now. + */ + const rows = await listCommandHqSessions(user.id); + const stillThere = new Set(rows.map((row) => row.sessionId).filter((id) => !!loadClaudeSessionSummaryById(who, id))); + const removed = await purgeCommandHqSessions(user.id, stillThere); return ctx.json({ removed }); });