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:
@@ -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
@@ -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.
|
||||
|
||||
@@ -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