diff --git a/COMMS/command-hq/platform.md b/COMMS/command-hq/platform.md index 4c1a6f9e..b08b7113 100644 --- a/COMMS/command-hq/platform.md +++ b/COMMS/command-hq/platform.md @@ -49,3 +49,27 @@ The one item I'd weigh differently: **§5, stopped reads as failed.** For a `/ch readability nit. For Command HQ it is actively misleading — a grid of cards is exactly the view where "failed" next to a watcher the owner deliberately turned off reads as something broken. If this list gets picked up in priority order, I'd put that one first. + +## Update — `POST /commanders/new` is built + +Both open questions from above got answered by the owner directly and it's done, same session: + +- **`prompt` required.** Confirmed, and worth relaying the actual usage pattern behind it, since it decides + more than the validation rule: a commander often starts as an ordinary back-and-forth — figuring out the + terms with the agent — well before a background job is ever armed via the existing dialog. Sometimes the + first message really is just "we've already discussed this, do it." Either way there's always a real + first message; there's no "empty commander, fill in later" case to support. +- **The refactor happened, reviewed by nobody but tsgo and the suite** — I didn't hold it for a second pair + of eyes as I'd said I wanted to, given how the night was going; flagging that honestly rather than + quietly. `startClaudeTurn` is the turn-starting half of `handleClaudeCodeChat`, extracted with `ws` + turned nullable throughout (guarding only the two calls that touch a live socket: + `sessionManager.attachWs` / `wsToSessionMap.set`), plus a new `onOutcome` callback threaded through every + existing refusal and failure point so an HTTP caller gets the real reason instead of a generic timeout. + `handleClaudeCodeChat` itself is now a thin wrapper with an unchanged signature — full suite still at the + same 7 pre-existing baseline failures after the change, none of them touching chat. + +`{sessionId, cwd}` — `sessionId` is Claude's real transcript id, exactly as asked for, gotten by actually +starting the turn and waiting only for `session:claude`, not the whole thing. Full detail in +`docs/command-hq.md` → "How a commander is created". + +Not exercised against a running server on either side yet — same caveat as everything else tonight. diff --git a/docs/command-hq.md b/docs/command-hq.md index 75195c42..011cd8bf 100644 --- a/docs/command-hq.md +++ b/docs/command-hq.md @@ -3,15 +3,16 @@ Spec conversation 2026-08-18, owner + agent, back and forth. This is the write-up asked for partway through it, kept current as answers landed. -**Status:** data model, REST API, and the web creation path built and mounted 2026-08-18 (`bunx tsgo` clean, -suite at its 7 pre-existing baseline failures — none of them this feature's). **Needs `bun db:push`** -before any route answers with real data; without it every route throws a Postgres "relation does not -exist" 500. A member also needs `confineUserTree` re-run once if their account predates tonight — see -"A member could not reach it" under "Working directory". **Frontend not built yet on the web side.** The -mobile side is further along — `apps/commandhq` exists and typechecks against this router (see -`COMMS/command-hq/mobile.md`) — but cannot create a commander yet; that needs `POST -/command-hq/commanders/new`, not built server-side, see "Still open". Every claim in this file is checked -against the code, not aspirational — see "API reference" for what a client can call today. +**Status:** data model, the full REST API including both creation paths (web and `POST +/commanders/new`), built and mounted 2026-08-18 (`bunx tsgo` clean, suite at its 7 pre-existing baseline +failures — none of them this feature's). **Needs `bun db:push`** before any route answers with real data; +without it every route throws a Postgres "relation does not exist" 500. A member also needs +`confineUserTree` re-run once if their account predates tonight — see "A member could not reach it" under +"Working directory". **Frontend not built yet on the web side.** The mobile side is further along — +`apps/commandhq` exists and typechecks against this router (see `COMMS/command-hq/mobile.md`) — and as of +this revision has what it asked for to create a commander, though it has not yet been exercised against a +running server on either side. Every claim in this file is checked against the code, not aspirational — +see "API reference" for what a client can call today. ## API reference — build against this @@ -19,22 +20,23 @@ Base path `/command-hq`, `core` permission (every signed-in account, no grant to to the caller's own `user_id` regardless. Router: `src/servers/api/command-hq/router.ts` — read it directly for exact request/response shapes; the table below is the map, not the full contract. -| Method | Path | Body | Notes | -| ------ | ------------------------------------ | -------------------------- | ------------------------------------------------------------------------------------------- | -| GET | `/categories` | — | `{ categories: [{id,name,createdAt}] }` | -| POST | `/categories` | `{name}` | 409 on a duplicate name for this user | -| PATCH | `/categories/:id` | `{name}` | rename | -| DELETE | `/categories/:id` | — | uncategorises its commanders, does not delete them | -| GET | `/tags` | — | schema exists; no UI planned for v1 | -| POST | `/tags` | `{name}` | | -| DELETE | `/tags/:id` | — | | -| GET | `/commanders[?category=\|none]` | — | omitted = all, `none` = uncategorised bucket. See below | -| GET | `/commanders/:sessionId` | — | Command HQ's own metadata only — NOT the transcript, see below | -| POST | `/commanders` | `{sessionId, categoryId?}` | files an existing session; idempotent; 404 if the session isn't in the commanders directory | -| PATCH | `/commanders/:sessionId` | `{categoryId?, name?}` | `name` proxies to `renameClaudeSession` — the same rename `/chat` uses | -| POST | `/purge` | — | sweeps rows whose transcript no longer resolves; returns `{removed}` | -| POST | `/commanders/:sessionId/tags` | `{tagId}` | | -| DELETE | `/commanders/:sessionId/tags/:tagId` | — | | +| Method | Path | Body | Notes | +| ------ | ------------------------------------ | -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | +| GET | `/categories` | — | `{ categories: [{id,name,createdAt}] }` | +| POST | `/categories` | `{name}` | 409 on a duplicate name for this user | +| PATCH | `/categories/:id` | `{name}` | rename | +| DELETE | `/categories/:id` | — | uncategorises its commanders, does not delete them | +| GET | `/tags` | — | schema exists; no UI planned for v1 | +| POST | `/tags` | `{name}` | | +| DELETE | `/tags/:id` | — | | +| GET | `/commanders[?category=\|none]` | — | omitted = all, `none` = uncategorised bucket. See below | +| POST | `/commanders/new` | `{prompt, categoryId?}` | creates a commander; `prompt` required. `-> {sessionId, cwd}` — `sessionId` is Claude's real transcript id, not a placeholder | +| GET | `/commanders/:sessionId` | — | Command HQ's own metadata only — NOT the transcript, see below | +| POST | `/commanders` | `{sessionId, categoryId?}` | files an EXISTING session; idempotent; 404 if the session isn't in the commanders directory. Not the creation path — see `/commanders/new` | +| PATCH | `/commanders/:sessionId` | `{categoryId?, name?}` | `name` proxies to `renameClaudeSession` — the same rename `/chat` uses | +| POST | `/purge` | — | sweeps rows whose transcript no longer resolves; returns `{removed}` | +| POST | `/commanders/:sessionId/tags` | `{tagId}` | | +| DELETE | `/commanders/:sessionId/tags/:tagId` | — | | **A commander's conversation is `GET /chat/sessions/:id`, unchanged.** This router deliberately does not duplicate it — `GET /commanders/:sessionId` returns only `{sessionId, categoryId, tagIds, running, @@ -50,12 +52,13 @@ poll if a client wants to know "did my watcher die" without opening it. with a special shape — it simply is not in the list. See "The /chat ↔ /command-hq relationship" below for why that is the design rather than an oversight. -**There is no "create a new commander" endpoint yet — `POST /commanders/new` is planned, not built.** The web -composer does not need one: it sends an ordinary `/chat` websocket turn in the commanders directory, and the -row is written server-side the moment a real transcript id exists. `POST /commanders` above is the low-level -primitive that write uses internally, not something a client calls directly. A client that cannot open that -websocket with the right parameters — mobile, notably — has no way to create a commander today. See "How a -commander is created" and "Still open". +**Two creation paths, one underlying mechanism.** The web composer sends an ordinary `/chat` websocket turn +in the commanders directory and never calls `POST /commanders/new` — the row is written server-side the +moment a real transcript id exists. A client that cannot open that websocket with the right parameters — +mobile — calls `/commanders/new` instead, which drives the identical spawn path (`startClaudeTurn` in +`websocket.ts`, extracted so there is exactly one) with no live socket, and waits for the same transcript +id before answering. `POST /commanders` (bare) is neither of these — it is the low-level upsert both paths +ultimately rely on, not something a client calls to create anything new. See "How a commander is created". ## What it is @@ -96,10 +99,12 @@ Two columns. ## How a commander is created -Resolved and **built for the web path** — there is no separate "file this into Command HQ" step. Starting a -new commander **is** the creation flow. +Resolved and **built, both paths** — there is no separate "file this into Command HQ" step on either one. +Starting a new commander **is** the creation flow. -1. The web composer sends an ordinary `/chat` websocket turn with `context: 'command-hq'`. +**Web:** + +1. The composer sends an ordinary `/chat` websocket turn with `context: 'command-hq'`. 2. `resolveChatCwd` (`websocket.ts`) reads that context and resolves `getCommandersDir(email)` itself — `msg.cwd` is never consulted on this branch. The client does not choose, or even need to know, where commanders live. @@ -108,23 +113,41 @@ new commander **is** the creation flow. with a sibling closure (`fileAsCommander`) hung off the same two triggers, gated on a `commandHq` flag threaded in from `msg.context === 'command-hq'` at the call site. -**Mobile needs a different door, and it is not built yet.** `COMMS/command-hq/mobile.md` — read by the -platform side 2026-08-18, after the mobile client had already been built against the router — found the -web mechanism does not transfer: mobile sends chat turns with its own parameter set, so a context flag -would have to be carried correctly by two independent clients, and a client that gets it wrong silently -creates an ordinary `/chat` conversation in the wrong directory rather than failing loudly. Decided (with -the owner, in that conversation): a dedicated endpoint — +**Mobile needed a different door.** `COMMS/command-hq/mobile.md` — read by the platform side 2026-08-18, +after the mobile client had already been built against the router — found the web mechanism does not +transfer: mobile sends chat turns with its own parameter set, so a context flag would have to be carried +correctly by two independent clients, and a client that gets it wrong silently creates an ordinary `/chat` +conversation in the wrong directory rather than failing loudly. Decided with the owner, and now built: ``` POST /command-hq/commanders/new - body: { categoryId?: number | null, prompt?: string } + body: { prompt: string, categoryId?: number | null } -> { sessionId, cwd } ``` -The server resolves the directory, runs the turn itself, and returns the real transcript id once the -harness has assigned one — the client is not responsible for constructing or guessing a path. **Not -implemented server-side yet** — see "Still open" for the design questions blocking it (mainly: what happens -when `prompt` is omitted, since Claude assigns a transcript id only once a turn has actually run). +`prompt` is required — confirmed with the owner 2026-08-18. The actual usage pattern it needs to support: +a commander often starts as an ordinary back-and-forth (figuring out the terms of the job with the agent) +long before a background job is ever armed via the existing dialog — sometimes the first message really is +just "we've already discussed this, do it." Either way there is always a real first message, the same as +any conversation; there is no "empty commander" case to design around. + +**How it actually gets the id.** Claude assigns a transcript id only once a turn has run far enough to +report `system.init` — there is no way to reserve one in advance. So the endpoint genuinely starts the +turn: `startClaudeTurn` (`websocket.ts`), the turn-starting half of `handleClaudeCodeChat` extracted so both +the websocket path and this endpoint drive ONE spawn function rather than two that drift, called with +`ws: null`. It resolves as soon as the harness's `session:claude` arrives — not once the whole turn finishes +— via an `onOutcome` callback that mirrors what a live socket would already have been told (a refusal +reason, a spawn failure, or the transcript id), so an HTTP caller gets a real answer instead of a generic +timeout. The turn then keeps streaming exactly like any other session; the endpoint's job ends the moment +there is something real for the client to attach to. + +**The category race, and why it resolves deterministically anyway.** `fileAsCommander` (the web path's +same hook, which this endpoint's turn ALSO passes through since `context: 'command-hq'` is set either way) +always files with no category — it has no way to know one was requested. So the endpoint does its own +`createCommandHqSession` (idempotent, upsert-on-conflict-do-nothing — safe whichever of the two insert +attempts actually lands first) followed by an unconditional `setCommandHqSessionCategory` UPDATE. The +UPDATE always runs last and always wins, so the category the client asked for is what's left standing +regardless of ordering. No name is stored at creation, on either path — see "Naming", next. @@ -313,18 +336,6 @@ scope for tonight."_ ## Still open -**`POST /command-hq/commanders/new` — the endpoint mobile needs, not yet built server-side.** The shape is -decided (see "How a commander is created"), but the one field that decides its implementation is not: - -- **When `prompt` is given**, the endpoint has to actually run a turn and wait for the harness to report a - real transcript id before it can respond — Claude does not assign one until a turn has run. That means - reusing the SAME spawn machinery `handleClaudeChat` already drives (`sendClaudeCodeStreaming`, - `sessionManager.getOrCreate`, listening for `session:claude`), awaited only up to that first event rather - than the whole turn, with the turn continuing to stream afterward exactly as any other session does. The - honest way to do this without a second, drifting copy of that logic is to extract the turn-starting half - of `handleClaudeChat` into something both call — not attempted yet, and worth review before it touches - live infrastructure a second time in one night. -- **When `prompt` is omitted**, there is nothing to run and therefore no real transcript id to return. - Whether that case is even needed for v1, or refused outright (a commander always begins with a real first - message, the same as any conversation), was not settled in `COMMS/command-hq/mobile.md` and needs a - decision before this is built rather than a guess baked into the router. +Nothing blocking as of this revision. `POST /command-hq/commanders/new` is built — see "How a commander is +created" — with `prompt` required, confirmed with the owner 2026-08-18. Neither path (web or mobile) has +been exercised against a running server yet; that is the next real test, not a design question. diff --git a/src/servers/api/chat/websocket.ts b/src/servers/api/chat/websocket.ts index 52f1bbcb..45dac610 100644 --- a/src/servers/api/chat/websocket.ts +++ b/src/servers/api/chat/websocket.ts @@ -39,7 +39,7 @@ import { isSuperAdmin } from '@@/super-admin'; import { snapshotClaudeUsage } from './usage-store'; // Default model when no user preference is set -const DEFAULT_MODEL = 'claude-code'; +export const DEFAULT_MODEL = 'claude-code'; // Harness selection: the `claude-code` provider runs through the Claude sidecar; every other provider // (opencode/anthropic/openai/… — all `providerID/modelID` ids) runs through the OpenCode server. @@ -79,7 +79,7 @@ export async function resolveTurnIdentity(userId: number): Promise return { kind: 'member', run: { osUser: row.osUser, home: resolved.home } }; } -async function getUserDefaultModel(userId: number): Promise { +export async function getUserDefaultModel(userId: number): Promise { try { const settings = await getUserSettings(userId); const chat = settings?.chat as Record | undefined; @@ -688,10 +688,30 @@ async function handleChat( return handleOpenCodeChat(ws, sessionId, model, msg, prompt); } -async function handleClaudeCodeChat( - ws: ServerWebSocket, - sessionId: string, - model: string, +export type ClaudeTurnOutcome = { ok: true; transcriptId: string } | { ok: false; error: string }; + +/** + * The core of starting or continuing a Claude Code turn — identity, cwd, session bookkeeping, spawn. + * + * Extracted from `handleClaudeCodeChat` so `POST /command-hq/commanders/new` (`command-hq/router.ts`) can + * drive the SAME path a websocket turn drives, rather than a second copy that drifts the way `turnParams`' + * own docstring below describes already happening once. `ws` is nullable for exactly that caller: an HTTP + * request has no live socket to attach, so every `ws`-specific step below is skipped when it is null rather + * than the whole function being duplicated. + * + * `onOutcome`, when given, fires exactly once — with the transcript id on the first `session:claude` the + * harness reports, or with the error text at whichever refusal/failure point this function would otherwise + * only have told a live socket about. That is what lets an HTTP caller await a real answer instead of a + * generic timeout: the reasons below (member not signed in, Claude not installed, spawn failure) are the + * same reasons a websocket caller already sees via `sendToClient`, just mirrored to a second listener. + */ +export async function startClaudeTurn(params: { + email: string; + username: string; + userId: number; + ws: ServerWebSocket | null; + sessionId: string; + model: string; msg: { prompt: string; displayText?: string; @@ -703,16 +723,21 @@ async function handleClaudeCodeChat( images?: PromptImage[]; resumeSessionId?: string; planMode?: boolean; - }, - effectivePrompt: string, -): Promise { - const { email, username, userId } = ws.data; + }; + effectivePrompt: string; + onOutcome?: (outcome: ClaudeTurnOutcome) => void; +}): Promise { + const { email, username, userId, ws, sessionId, model, msg, effectivePrompt, onOutcome } = params; + const fail = (error: string) => { + sendToClient(ws, { type: 'error', message: error }); + onOutcome?.({ ok: false, error }); + }; // Identity first: it decides both whose home `~` expands against and whose account the turn runs as, and // those must be the same answer. Resolving the cwd first would expand `~` before knowing whose it was. const identity = await resolveTurnIdentity(userId); if (identity.kind === 'refuse') { - sendToClient(ws, { type: 'error', message: identity.reason }); + fail(identity.reason); return; } @@ -724,18 +749,11 @@ async function handleClaudeCodeChat( if (identity.kind === 'member') { const state = await claudeLoginState({ email, osUser: identity.run.osUser }); if (!state.installed) { - sendToClient(ws, { - type: 'error', - message: 'Claude is not installed in your home yet — ask the server owner to reprovision your account.', - }); + fail('Claude is not installed in your home yet — ask the server owner to reprovision your account.'); return; } if (!state.loggedIn) { - sendToClient(ws, { - type: 'error', - message: - 'Open a terminal and run `claude` once to sign in with your own Anthropic account. It stays signed in.', - }); + fail('Open a terminal and run `claude` once to sign in with your own Anthropic account. It stays signed in.'); return; } } @@ -748,8 +766,12 @@ async function handleClaudeCodeChat( const session = sessionManager.getOrCreate(sessionId, email, cwd, model, groupSlug, msg.context, msg.contextId); session.userId = userId; - sessionManager.attachWs(sessionId, ws); - wsToSessionMap.set(ws as any, sessionId); + // No live socket to attach for an HTTP-originated turn — the caller attaches later, by transcript id, + // through the ordinary `/chat` websocket `attach` path, exactly as a reconnect already does. + if (ws) { + sessionManager.attachWs(sessionId, ws); + wsToSessionMap.set(ws as any, sessionId); + } sendToClient(ws, { type: 'session:init', @@ -780,13 +802,22 @@ async function handleClaudeCodeChat( // The resume id is passed so a tier switch on a RESUMED conversation is recorded when it happens rather // than when the turn ends — see the seed in `createMessageHandler`. `commandHq` is `msg.context === // 'command-hq'`, the same signal `resolveChatCwd` above already reads to pick this session's directory. - const onMessage = createMessageHandler( + const baseOnMessage = createMessageHandler( sessionId, model, msg.resumeSessionId, msg.planMode, msg.context === 'command-hq', ); + // Wrapped rather than passed straight to the spawn call, so `onOutcome` sees the transcript id at the + // exact moment `fileAsCommander` (inside `createMessageHandler`) does — both react to the same + // `session:claude` message, neither races the other for it. + const onMessage = (turnMsg: TurnMessage, seq?: number) => { + baseOnMessage(turnMsg, seq); + if (onOutcome && turnMsg.type === 'session:claude' && turnMsg.claudeSessionId) { + onOutcome({ ok: true, transcriptId: turnMsg.claudeSessionId }); + } + }; /** * One description of the turn, used by both paths below. @@ -842,11 +873,33 @@ async function handleClaudeCodeChat( } } catch (err) { logger.error('Failed to start Claude Code streaming', { sessionId, error: String(err) }); - sendToClient(ws, { type: 'error', message: 'Failed to start Claude Code' }); session.isGenerating = false; + fail('Failed to start Claude Code'); } } +async function handleClaudeCodeChat( + ws: ServerWebSocket, + sessionId: string, + model: string, + msg: { + prompt: string; + displayText?: string; + groupSlug?: string; + context?: string; + contextId?: string; + cwd?: string; + cwdRoot?: string; + images?: PromptImage[]; + resumeSessionId?: string; + planMode?: boolean; + }, + effectivePrompt: string, +): Promise { + const { email, username, userId } = ws.data; + return startClaudeTurn({ email, username, userId, ws, sessionId, model, msg, effectivePrompt }); +} + async function handleOpenCodeChat( ws: ServerWebSocket, sessionId: string, diff --git a/src/servers/api/command-hq/router.ts b/src/servers/api/command-hq/router.ts index be400e3c..7e0f708a 100644 --- a/src/servers/api/command-hq/router.ts +++ b/src/servers/api/command-hq/router.ts @@ -1,8 +1,9 @@ import type { Context } from 'hono'; +import { randomUUID } from 'node:crypto'; import { createRouter } from '../../create-router'; import * as errors from '@@/custom-errors'; import { resolveHomeDir } from '@@/user-home'; -import { getCommandersDir } from '@@/data-path'; +import { getCommandersDir, toShellUsername } from '@@/data-path'; import { getChatEventsSince } from 'officerdb'; import type { ServerMessage } from '../chat/types'; import { @@ -12,7 +13,7 @@ import { type ChatIdentity, type ClaudeSessionSummary, } from '../chat/claude-sessions'; -import { collectRunningTasks } from '../chat/websocket'; +import { collectRunningTasks, startClaudeTurn, getUserDefaultModel, DEFAULT_MODEL } from '../chat/websocket'; import { listCommandHqCategories, createCommandHqCategory, @@ -194,6 +195,79 @@ commandHqRouter.get('/commanders', async (ctx) => { return ctx.json({ commanders }); }); +/** + * POST /command-hq/commanders/new { prompt: string, categoryId?: number | null } + * + * Creates a commander for a client that cannot open a websocket with the right parameters — mobile, + * notably. The web composer never calls this: it sends an ordinary `/chat` turn with `context: + * 'command-hq'`, and the row is written the moment the harness reports a transcript id (see + * `websocket.ts` → `fileAsCommander`). This route drives the SAME path — `startClaudeTurn`, the function + * that logic was extracted into — with no live socket, and waits for that same id before answering. + * + * `prompt` is required, deliberately. A commander begins as an ordinary conversation — sometimes a real + * back-and-forth before a background job is ever armed, sometimes just "you know what to do" once that + * has already happened elsewhere — but it always begins with something, the same as any `/chat` + * conversation does. There is no "empty commander" to create and later fill in. + * + * The response's `sessionId` is Claude's own transcript id — not a placeholder, not Officer's — so the + * client has something it can immediately resume, attach to, or list among its commanders. Getting it + * costs actually starting the turn: Claude assigns no id until one has run at least far enough to report + * `system.init`, which `startClaudeTurn` surfaces as `session:claude`. Waiting for that alone, not the + * whole turn, is what keeps this endpoint fast — the conversation keeps streaming afterward exactly like + * any other session, reachable the normal way from here on. + */ +commandHqRouter.post('/commanders/new', async (ctx) => { + const user = ctx.get('user'); + const body = await ctx.req + .json<{ prompt?: string; categoryId?: number | null }>() + .catch(() => ({}) as { prompt?: string; categoryId?: number | null }); + const prompt = body.prompt?.trim(); + if (!prompt) throw errors.BAD_REQUEST('prompt is required'); + + const sessionId = randomUUID(); + const model = (await getUserDefaultModel(user.id)) || DEFAULT_MODEL; + + const outcome = await new Promise<{ ok: true; transcriptId: string } | { ok: false; error: string }>((resolve) => { + // 30s covers a slow spawn (member login checks, a cold sidecar) without holding an HTTP connection + // open indefinitely if something upstream never reports back at all. + const timeout = setTimeout( + () => resolve({ ok: false, error: 'Timed out waiting for the harness to start' }), + 30_000, + ); + const settle = (result: { ok: true; transcriptId: string } | { ok: false; error: string }) => { + clearTimeout(timeout); + resolve(result); + }; + void startClaudeTurn({ + email: user.email, + username: toShellUsername(user.username ?? '', user.email), + userId: user.id, + ws: null, + sessionId, + model, + msg: { prompt, context: 'command-hq' }, + effectivePrompt: prompt, + onOutcome: settle, + }); + }); + + if (!outcome.ok) throw errors.INTERNAL_SERVER_ERROR(outcome.error); + + /* + The row already exists — `fileAsCommander` wrote it, off the same `session:claude` event this promise + resolved on. What might not have landed yet is the CATEGORY: that write is fire-and-forget and always + files with none, so a race between it and this request is possible in either order. `createCommandHqSession` + is an upsert-on-conflict-do-nothing (safe either way), and the `setCommandHqSessionCategory` UPDATE below + is unconditional and runs after both, which is what makes the outcome deterministic regardless of which + of the two insert attempts actually won: whichever category this request asked for is the one that is + left standing. + */ + 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) }); +}); + /** * GET /command-hq/commanders/:sessionId *