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
+66 -21
View File
@@ -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<ChatIdentity> {
@@ -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<typeof s> => !!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 });
});