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:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user