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
+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
*