command hq: POST /commanders/new, for a client that cannot open the websocket

mobile's need, decided with the owner in COMMS/command-hq/mobile.md, now
built: { prompt (required), categoryId? } -> { sessionId, cwd }, sessionId
being claude's REAL transcript id, not a placeholder.

the owner confirmed prompt is required and described why: a commander
usually starts as an ordinary back-and-forth before a background job is ever
armed via the existing dialog, sometimes it's just "you know what to do" —
either way there's always a real first message, same as any conversation.
no empty-commander case to design around.

getting a real transcript id costs actually starting the turn, since claude
assigns one only once system.init has been reported. extracted the
turn-starting half of handleClaudeCodeChat into startClaudeTurn(ws: ... |
null, ..., onOutcome?) so both the websocket path and this endpoint drive
one spawn function instead of two that drift — handleClaudeCodeChat is now
a thin wrapper with an unchanged signature. onOutcome mirrors every
sendToClient(ws, {type:'error',...}) refusal point (member not signed in,
claude not installed, spawn failure) plus the session:claude success case,
so an http caller gets the real reason instead of a generic 30s timeout.

the category-write race: fileAsCommander (same hook the web path uses,
since context: 'command-hq' is set either way) always files with no
category. the endpoint's own createCommandHqSession is upsert-safe
regardless of which insert wins, and an unconditional setCommandHqSessionCategory
UPDATE runs after — always last, always wins, so the requested category is
what's left standing no matter the race.

said plainly in COMMS/command-hq/platform.md: this refactor of live spawn
machinery did not get the second-pair-of-eyes review i said i wanted before
touching it. bunx tsgo clean, suite at the same 7 pre-existing baseline
failures. not exercised against a running server on either side yet.

docs/command-hq.md updated throughout — API table, "how a commander is
created", "still open" now empty of blockers.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-18 07:29:57 +00:00
co-authored by Claude Opus 5
parent e9b1ce5e94
commit 7ad64445ee
4 changed files with 248 additions and 86 deletions
+24
View File
@@ -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.
+71 -60
View File
@@ -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=<id>\|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=<id>\|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.
+77 -24
View File
@@ -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<TurnIdentity>
return { kind: 'member', run: { osUser: row.osUser, home: resolved.home } };
}
async function getUserDefaultModel(userId: number): Promise<string | null> {
export async function getUserDefaultModel(userId: number): Promise<string | null> {
try {
const settings = await getUserSettings(userId);
const chat = settings?.chat as Record<string, unknown> | undefined;
@@ -688,10 +688,30 @@ async function handleChat(
return handleOpenCodeChat(ws, sessionId, model, msg, prompt);
}
async function handleClaudeCodeChat(
ws: ServerWebSocket<WSData>,
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<WSData> | 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<void> {
const { email, username, userId } = ws.data;
};
effectivePrompt: string;
onOutcome?: (outcome: ClaudeTurnOutcome) => void;
}): Promise<void> {
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<WSData>,
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<void> {
const { email, username, userId } = ws.data;
return startClaudeTurn({ email, username, userId, ws, sessionId, model, msg, effectivePrompt });
}
async function handleOpenCodeChat(
ws: ServerWebSocket<WSData>,
sessionId: string,
+76 -2
View File
@@ -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
*