a commander runs where the owner chose, and Command HQ stops scanning one folder

Two of the three things the identity change unblocked.

THE WORKING DIRECTORY. `resolveChatCwd` forced `getCommandersDir(email)` and ignored `msg.cwd` entirely —
every commander shared one empty directory whose only purpose was to BE a cwd, because the cwd was how a
commander was recognised. It honours the choice now, resolved through the same `resolveCwd(home, …)`
containment the ordinary chat branch uses, so "client-chosen" still cannot mean "anywhere outside the
account's tree". No fallback to the old shared folder: the UI will require a directory, and an absent one
falls back to the account's home, which is the honest last resort rather than a silent special case.

THE LIST. Command HQ found its commanders by scanning that one directory, which would now find only the ones
that happen to still live there and silently hide every other. It reads its rows and loads each transcript by
id — `loadClaudeSessionSummaryById`, added here — a handful of lookups rather than a directory walk. A row
whose transcript is nowhere is treated as absent exactly as a missing file was.

`POST /purge` had the same scan and it was the dangerous one: unfiling every commander that chose a different
directory, from a housekeeping route, when the rows are what identity now depends on.

Three smaller corrections that came with it:

- rename and delete passed the commanders directory as their cwd argument. It is documented as a HINT that
  falls back to locating the transcript anywhere, so they worked — but passing a path we know is usually
  wrong reads as intent later. They pass `who.home` now.
- `POST /command-hq/commanders/new` returned `cwd: getCommandersDir(...)` regardless of where the commander
  actually ran, and now accepts `cwd` and `name` like the UI will, and reports what it was given.
- the router's "Where commanders live" section said every commander shares one directory and that listing is
  therefore a scan. Both sentences were the design being replaced.

`loadClaudeSessionSummaryById` takes its mtime from `listTranscriptsAs` rather than a bare stat: the file may
belong to a member, and reading a member's tree needs the as-user path. That is one scoped directory listing
per commander — the same call the old whole-folder scan made, once per row instead of once for all, which is
the price of letting each choose where it lives.

Verified on live data: the existing commander still resolves, with its title and its real updatedAt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-22 19:06:19 +00:00
co-authored by Claude Opus 5
parent d08ae41f1f
commit 2bd8701b15
3 changed files with 123 additions and 38 deletions
+31 -1
View File
@@ -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 { DATA_PATH, getCommandersDir } from '../../data-path';
import { import {
appendTextAs, 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. * Every conversation in every directory, newest first.
* *
+26 -16
View File
@@ -15,12 +15,7 @@ import { sendClaudeCodeStreaming } from '@@/channels/send-claude-code';
import { sendOpenCodeStreaming } from '@@/channels/send-opencode'; import { sendOpenCodeStreaming } from '@@/channels/send-opencode';
import * as sidecar from '@@/sidecar-registry'; import * as sidecar from '@@/sidecar-registry';
import { join } from 'path'; import { join } from 'path';
import { import { getOwnerHomeDir, getEmailAccountsDir, findEmailAccountName } from '../../../servers/data-path';
getOwnerHomeDir,
getEmailAccountsDir,
findEmailAccountName,
getCommandersDir,
} from '../../../servers/data-path';
import { attachedFilePaths } from './attached-files'; import { attachedFilePaths } from './attached-files';
import { import {
getUserSettings, getUserSettings,
@@ -155,16 +150,25 @@ async function resolveChatCwd(
userId: number, userId: number,
home: string, home: string,
): Promise<string> { ): Promise<string> {
// 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 A commander runs where the owner chose, and the choice is required.
// half of a turn already takes: resolved by the platform, never trusted from the caller.
// This used to force `getCommandersDir(email)` and ignore `msg.cwd` entirely — every commander shared one
// `mkdirSync` — synchronous, so this returns only once the directory genuinely exists, the same pattern empty directory whose only job was to BE a cwd, because the cwd was how a commander was recognised.
// `resolveEmailCwd` below already uses. No race: unlike the rejected per-commander design, this directory Identity is the `command_hq_sessions` row now, so the directory is free to be useful: a commander that
// is shared and only the very first commander ever needs the mkdir to do anything; `recursive: true` works on the platform can live in the platform checkout, where its relative paths mean something and its
// makes every call after that a no-op stat. 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') { if (msg.context === 'command-hq') {
const dir = getCommandersDir(email); const dir = msg.cwd?.trim() ? resolveCwd(home, msg.cwd) : home;
mkdirSync(dir, { recursive: true }); mkdirSync(dir, { recursive: true });
return dir; return dir;
} }
@@ -479,7 +483,11 @@ function createMessageHandler(
if (!user?.email) return; if (!user?.email) return;
const who = await chatIdentity({ id: userId, email: user.email }).catch(() => null); const who = await chatIdentity({ id: userId, email: user.email }).catch(() => null);
if (!who) return; 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) => { .catch((err: unknown) => {
logger.warn('Could not file this session as a commander', { logger.warn('Could not file this session as a commander', {
@@ -896,6 +904,8 @@ export async function startClaudeTurn(params: {
images?: PromptImage[]; images?: PromptImage[];
resumeSessionId?: string; resumeSessionId?: string;
planMode?: boolean; planMode?: boolean;
/** See `commanderName` on the wire type — the name chosen before a commander's first message. */
commanderName?: string;
armWatcher?: boolean; armWatcher?: boolean;
}; };
effectivePrompt: string; effectivePrompt: string;
+66 -21
View File
@@ -17,6 +17,7 @@ import type { ServerMessage } from '../chat/types';
import { import {
listClaudeSessions, listClaudeSessions,
loadClaudeSessionById, loadClaudeSessionById,
loadClaudeSessionSummaryById,
renameClaudeSession, renameClaudeSession,
deleteClaudeSession, deleteClaudeSession,
type ChatIdentity, type ChatIdentity,
@@ -62,11 +63,17 @@ import {
* *
* ── Where commanders live ── * ── Where commanders live ──
* *
* Every commander shares ONE working directory per user — `getCommandersDir(email)` — never one per * **Wherever the owner chose.** Each commander names its own working directory when it is created, and that
* commander. That means listing a user's commanders is exactly `listClaudeSessions` (the same function * directory is where its relative paths resolve and where its transcripts are grouped.
* `/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 * It was one shared directory per user until 2026-08-22, because the directory WAS the identity — the only
* transcript reader `/chat` already is. * 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<ChatIdentity> { async function commandHqIdentity(user: { id: number; email: string }): Promise<ChatIdentity> {
@@ -201,12 +208,24 @@ commandHqRouter.get('/commanders', async (ctx) => {
const categoryParam = ctx.req.query('category'); const categoryParam = ctx.req.query('category');
const categoryId = categoryParam === undefined ? undefined : categoryParam === 'none' ? null : Number(categoryParam); const categoryId = categoryParam === undefined ? undefined : categoryParam === 'none' ? null : Number(categoryParam);
const [rows, live] = await Promise.all([ /*
listCommandHqSessions(user.id, categoryId), Each commander's transcript is loaded BY ID, not found by scanning one directory.
listClaudeSessions(who, getCommandersDir(who.email)),
]);
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<typeof s> => !!s);
const liveById = new Map(summaries.map((s) => [s.id, s]));
const filed = rows.filter((row) => liveById.has(row.sessionId)); const filed = rows.filter((row) => liveById.has(row.sessionId));
const [tagsByRow, lastFiredByRow, runningById, costByRow] = await Promise.all([ const [tagsByRow, lastFiredByRow, runningById, costByRow] = await Promise.all([
@@ -272,8 +291,10 @@ commandHqRouter.get('/commanders', async (ctx) => {
commandHqRouter.post('/commanders/new', async (ctx) => { commandHqRouter.post('/commanders/new', async (ctx) => {
const user = ctx.get('user'); const user = ctx.get('user');
const body = await ctx.req const body = await ctx.req
.json<{ 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 }); .catch(
() => ({}) as { prompt?: string; displayText?: string; categoryId?: number | null; cwd?: string; name?: string },
);
const prompt = body.prompt?.trim(); const prompt = body.prompt?.trim();
if (!prompt) throw errors.BAD_REQUEST('prompt is required'); if (!prompt) throw errors.BAD_REQUEST('prompt is required');
@@ -309,7 +330,17 @@ commandHqRouter.post('/commanders/new', async (ctx) => {
ws: null, ws: null,
sessionId, sessionId,
model, 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, effectivePrompt: prompt,
onOutcome: settle, onOutcome: settle,
}); });
@@ -329,7 +360,9 @@ commandHqRouter.post('/commanders/new', async (ctx) => {
await createCommandHqSession(user.id, outcome.transcriptId, body.categoryId ?? null); await createCommandHqSession(user.id, outcome.transcriptId, body.categoryId ?? null);
await setCommandHqSessionCategory(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(); const sessionId = body.sessionId?.trim();
if (!sessionId) throw errors.BAD_REQUEST('sessionId is required'); if (!sessionId) throw errors.BAD_REQUEST('sessionId is required');
const live = listClaudeSessions(who, getCommandersDir(who.email)); // Located by id wherever it lives, not required to be in the old shared directory. A commander chooses
if (!live.some((s) => s.id === sessionId)) throw errors.NOT_FOUND('no such session in the commanders directory'); // 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); const row = await createCommandHqSession(user.id, sessionId, body.categoryId ?? null);
return ctx.json(row); return ctx.json(row);
@@ -458,7 +493,9 @@ commandHqRouter.patch('/commanders/:sessionId', async (ctx) => {
await setCommandHqSessionCategory(user.id, sessionId, body.categoryId); await setCommandHqSessionCategory(user.id, sessionId, body.categoryId);
} }
if (body.name !== undefined) { 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 (!ok) throw errors.NOT_FOUND('transcript not found');
} }
if (body.watcherArmed === false) { 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 // 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 // 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. // 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 { } catch {
// Already gone, or never written. Counted as not-deleted rather than failing the request. // 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); clearSpawnedRecord(who.email, sessionId);
// 3. The commander itself. // 3. The commander itself.
deleteClaudeSession(who, getCommandersDir(who.email), sessionId); deleteClaudeSession(who, who.home, sessionId);
await deleteCommandHqSession(user.id, 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 // 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) => { commandHqRouter.post('/purge', async (ctx) => {
const user = ctx.get('user'); const user = ctx.get('user');
const who = await commandHqIdentity(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 }); return ctx.json({ removed });
}); });