|
|
|
@@ -15,7 +15,12 @@ import { sendClaudeCodeStreaming } from '@@/channels/send-claude-code';
|
|
|
|
|
import { sendOpenCodeStreaming } from '@@/channels/send-opencode';
|
|
|
|
|
import * as sidecar from '@@/sidecar-registry';
|
|
|
|
|
import { join } from 'path';
|
|
|
|
|
import { getOwnerHomeDir, getEmailAccountsDir, findEmailAccountName } from '../../../servers/data-path';
|
|
|
|
|
import {
|
|
|
|
|
getOwnerHomeDir,
|
|
|
|
|
getEmailAccountsDir,
|
|
|
|
|
findEmailAccountName,
|
|
|
|
|
getCommanderDir,
|
|
|
|
|
} from '../../../servers/data-path';
|
|
|
|
|
import { attachedFilePaths } from './attached-files';
|
|
|
|
|
import {
|
|
|
|
|
getUserSettings,
|
|
|
|
@@ -24,16 +29,15 @@ import {
|
|
|
|
|
getUserById,
|
|
|
|
|
setChatSessionModel,
|
|
|
|
|
setChatSessionPlanMode,
|
|
|
|
|
createCommandHqSession,
|
|
|
|
|
setCommanderSessionId,
|
|
|
|
|
getCommandHqSession,
|
|
|
|
|
getCommandHqSessionById,
|
|
|
|
|
armCommandHqWatcher,
|
|
|
|
|
recordCommandHqWatcherFired,
|
|
|
|
|
recordCommandHqTurn,
|
|
|
|
|
} from 'officerdb';
|
|
|
|
|
import { resolveHomeDir } from '@@/user-home';
|
|
|
|
|
import { renameClaudeSession, type ChatIdentity } from './claude-sessions';
|
|
|
|
|
import { chatIdentity } from './chat';
|
|
|
|
|
import { commanderBlock } from '../command-hq/commander-block';
|
|
|
|
|
import { claudeLoginState } from '@@/os-user-claude';
|
|
|
|
|
import { mkdirSync } from 'node:fs';
|
|
|
|
|
import { logger } from './logger';
|
|
|
|
@@ -146,30 +150,44 @@ async function resolveEmailCwd(ownerEmail: string, accountEmail?: string): Promi
|
|
|
|
|
// The working directory a chat turn runs in, by context: email → the account dir; /chat → a chosen
|
|
|
|
|
// pwd or the caller's own home; everything else (browser/project/dashboard) → the given cwd.
|
|
|
|
|
async function resolveChatCwd(
|
|
|
|
|
msg: { context?: string; contextId?: string; cwd?: string },
|
|
|
|
|
msg: { context?: string; contextId?: string; cwd?: string; resumeSessionId?: string; commanderId?: number },
|
|
|
|
|
email: string,
|
|
|
|
|
userId: number,
|
|
|
|
|
home: string,
|
|
|
|
|
): Promise<string> {
|
|
|
|
|
/*
|
|
|
|
|
A commander runs where the owner chose, and the choice is required.
|
|
|
|
|
A commander runs in its OWN directory — resolved here, server-side, never trusted from `msg.cwd`.
|
|
|
|
|
|
|
|
|
|
This used to force `getCommandersDir(email)` and ignore `msg.cwd` entirely — every commander shared one
|
|
|
|
|
empty directory whose only job was to BE a cwd, because the cwd was how a commander was recognised.
|
|
|
|
|
Identity is the `command_hq_sessions` row now, so the directory is free to be useful: a commander that
|
|
|
|
|
works on the platform can live in the platform checkout, where its relative paths mean something and its
|
|
|
|
|
transcripts sit beside the work they are about.
|
|
|
|
|
This honoured a client-chosen `msg.cwd` from 2026-08-22 until `docs/commander-directory.md` reversed
|
|
|
|
|
that call two days later, and the reversal is not just "a crafted message could misdirect a commander" —
|
|
|
|
|
the CONTAINMENT reasoning that made a client-chosen path safe (`resolveCwd(home, …)`, so "client-chosen"
|
|
|
|
|
never meant "anywhere") still holds, it just no longer applies, because the client has nothing left to
|
|
|
|
|
choose. `POST /command-hq/commanders` creates the row, ITS directory and `CLAUDE.md` before any turn
|
|
|
|
|
spawns, so this only ever has to look the directory back up: by `commanderId` on the turn that just
|
|
|
|
|
created the row (no transcript id yet to look up by), or by the transcript id (`resumeSessionId`, or the
|
|
|
|
|
officer session's own resume id on a later turn of the same conversation) once one exists. Either lookup
|
|
|
|
|
lands on the same row, and `getCommanderDir(email, row.dirName)` is the one true answer either way.
|
|
|
|
|
|
|
|
|
|
The chosen path is resolved through `resolveCwd(home, …)`, the same containment the ordinary chat branch
|
|
|
|
|
below uses — so "client-chosen" still does not mean "anywhere". A commander cannot be pointed outside
|
|
|
|
|
the account's own tree by a crafted message.
|
|
|
|
|
|
|
|
|
|
No fallback to the old shared directory when nothing is chosen: the UI requires a directory before the
|
|
|
|
|
first message can be sent, and a silent default would be a commander running somewhere nobody picked.
|
|
|
|
|
Falling back to the account's home is the honest last resort for a caller that predates the requirement.
|
|
|
|
|
Falls back to the account's home only when NEITHER is given, which should not happen once every path
|
|
|
|
|
pre-creates the row — the honest last resort for a caller that predates it, not a real branch anybody
|
|
|
|
|
expects to take.
|
|
|
|
|
*/
|
|
|
|
|
if (msg.context === 'command-hq') {
|
|
|
|
|
const dir = msg.cwd?.trim() ? resolveCwd(home, msg.cwd) : home;
|
|
|
|
|
const row =
|
|
|
|
|
msg.commanderId !== undefined
|
|
|
|
|
? await getCommandHqSessionById(userId, msg.commanderId)
|
|
|
|
|
: msg.resumeSessionId
|
|
|
|
|
? await getCommandHqSession(userId, msg.resumeSessionId)
|
|
|
|
|
: null;
|
|
|
|
|
if (!row) {
|
|
|
|
|
// Loud, because the degradation is otherwise invisible: a commander running in the account's home has
|
|
|
|
|
// no CLAUDE.md, so it knows nothing about itself and asks the owner what its job is on every turn —
|
|
|
|
|
// which reads as the agent being forgetful rather than as a lookup that missed.
|
|
|
|
|
console.warn(
|
|
|
|
|
`[command-hq] no commander row for ${msg.commanderId !== undefined ? `id ${msg.commanderId}` : `session ${msg.resumeSessionId ?? '(none given)'}`} — falling back to the account home`,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
const dir = row ? getCommanderDir(email, row.dirName) : home;
|
|
|
|
|
mkdirSync(dir, { recursive: true });
|
|
|
|
|
return dir;
|
|
|
|
|
}
|
|
|
|
@@ -358,14 +376,12 @@ function createMessageHandler(
|
|
|
|
|
planMode?: boolean,
|
|
|
|
|
commandHq?: boolean,
|
|
|
|
|
/**
|
|
|
|
|
* The name the owner chose before the conversation started, for a commander.
|
|
|
|
|
*
|
|
|
|
|
* Claude Code will not accept a name for a NEW session — the harness names its own — so the platform
|
|
|
|
|
* renames it the moment the transcript exists. Done here rather than by the client because it must not
|
|
|
|
|
* depend on a browser still being open: the name was chosen before the first message, and losing it to a
|
|
|
|
|
* closed tab would leave a commander called after whatever it happened to say first.
|
|
|
|
|
* The commander row THIS turn belongs to — `command_hq_sessions.id`, created by `POST /commanders`
|
|
|
|
|
* before this turn ever started (`docs/commander-directory.md`). Used to point that row at the transcript
|
|
|
|
|
* id the moment one exists, so a commander's own row and directory (name, `CLAUDE.md`, `dirName`) are
|
|
|
|
|
* never something this handler creates — only something it finishes wiring up.
|
|
|
|
|
*/
|
|
|
|
|
commanderName?: string,
|
|
|
|
|
commanderId?: number,
|
|
|
|
|
) {
|
|
|
|
|
/*
|
|
|
|
|
The model messages are ATTRIBUTED to, which is not always the model the turn asked for.
|
|
|
|
@@ -451,68 +467,33 @@ function createMessageHandler(
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/*
|
|
|
|
|
File this session as a commander, once the transcript id is known — the same wait `record` above is
|
|
|
|
|
built around, and for the same reason: there is nothing to key the row on before the harness announces
|
|
|
|
|
it. Gated on `commandHq`, which the caller sets only when `msg.context === 'command-hq'`, so an
|
|
|
|
|
ordinary `/chat` turn never touches this table.
|
|
|
|
|
Point this commander's row at its transcript id, once the transcript id is known — the same wait
|
|
|
|
|
`record` above is built around, and for the same reason: there is nothing to key the write on before the
|
|
|
|
|
harness announces it. Gated on `commandHq && commanderId`, so an ordinary `/chat` turn never touches
|
|
|
|
|
this table, and a commander turn with no id (should not happen once every caller pre-creates the row —
|
|
|
|
|
see `docs/commander-directory.md`) is a no-op rather than a guess at creating one.
|
|
|
|
|
|
|
|
|
|
Idempotent by construction (`createCommandHqSession` upserts on `(user_id, session_id)`), so calling it
|
|
|
|
|
again on every later turn of the same commander — which happens, because the client keeps sending
|
|
|
|
|
`context: 'command-hq'` for `resolveChatCwd` to keep resolving the right directory — costs one no-op
|
|
|
|
|
upsert rather than a duplicate row. That repetition is also a cheap self-heal: if the row were ever lost,
|
|
|
|
|
the next turn puts it back.
|
|
|
|
|
`setCommanderSessionId` is a plain UPDATE, not an upsert: the row, its directory and its name all exist
|
|
|
|
|
already, written by `POST /commanders` before this turn started. There is nothing left to create here —
|
|
|
|
|
only a pointer to set, which is why this is so much smaller than the `fileAsCommander` it replaces used
|
|
|
|
|
to be. Idempotent by construction (an UPDATE by id), so calling it again on every later turn of the same
|
|
|
|
|
commander costs one no-op write, and is a cheap self-heal if the pointer were ever lost.
|
|
|
|
|
|
|
|
|
|
Fire-and-forget, swallowed failure, same posture as `record`: a database hiccup here must not be a
|
|
|
|
|
reason a chat turn fails.
|
|
|
|
|
*/
|
|
|
|
|
// Whether the chosen name has actually reached the transcript. False until a rename RETURNS true — see
|
|
|
|
|
// the retry note inside `fileAsCommander`.
|
|
|
|
|
let commanderNamed = false;
|
|
|
|
|
|
|
|
|
|
const fileAsCommander = (): void => {
|
|
|
|
|
if (!commandHq || !transcriptId) return;
|
|
|
|
|
if (!commandHq || !transcriptId || commanderId === undefined) return;
|
|
|
|
|
const userId = sessionManager.getSession(sessionId)?.userId;
|
|
|
|
|
if (!userId) return;
|
|
|
|
|
const filedId = transcriptId;
|
|
|
|
|
// The name goes in with the row, not only to the transcript: the list reads it from here and is
|
|
|
|
|
// right immediately, rather than waiting on a summary record the harness cannot write until the
|
|
|
|
|
// first turn has finished.
|
|
|
|
|
void createCommandHqSession(userId, filedId, null, commanderName?.trim() || null)
|
|
|
|
|
.then(async () => {
|
|
|
|
|
/*
|
|
|
|
|
The chosen name, applied once — and RETRIED, because the first attempt usually fails.
|
|
|
|
|
|
|
|
|
|
`fileAsCommander` runs on `session:claude`, which arrives at the START of a turn: the transcript
|
|
|
|
|
file may not be on disk yet, and `renameClaudeSession` returns false when it cannot find one. It
|
|
|
|
|
returns a boolean that nothing was checking, so the name was silently dropped and the conversation
|
|
|
|
|
kept the harness's own `ai-title` — which is exactly what the first live test showed.
|
|
|
|
|
|
|
|
|
|
So the attempt is recorded and retried at the end of the turn, by which point the file certainly
|
|
|
|
|
exists. Idempotent: once it succeeds, `commanderNamed` stops it repeating.
|
|
|
|
|
|
|
|
|
|
It still writes a `summary` record, which sets `hasExplicitTitle` — and an explicit title is never
|
|
|
|
|
overwritten by `ai-title` or by the first-message fallback. So it wins whenever it lands.
|
|
|
|
|
*/
|
|
|
|
|
if (!commanderName?.trim() || commanderNamed) return;
|
|
|
|
|
// The same identity resolution every chat route uses, rather than a second copy of it: it is what
|
|
|
|
|
// decides the home and the OS user, and a rename writes a file as that user.
|
|
|
|
|
const user = await getUserById(userId).catch(() => null);
|
|
|
|
|
if (!user?.email) return;
|
|
|
|
|
const who = await chatIdentity({ id: userId, email: user.email }).catch(() => null);
|
|
|
|
|
if (!who) return;
|
|
|
|
|
// `who.home` as the hint, not the old shared commanders directory: a commander chooses its own
|
|
|
|
|
// working directory now, so that path is usually wrong. `findTranscript` treats the cwd as a HINT
|
|
|
|
|
// and locates the transcript wherever it actually is — so this works either way, and passing a
|
|
|
|
|
// path we know is probably wrong would be the kind of thing that reads as intent later.
|
|
|
|
|
commanderNamed = renameClaudeSession(who, who.home, filedId, commanderName.trim());
|
|
|
|
|
})
|
|
|
|
|
.catch((err: unknown) => {
|
|
|
|
|
logger.warn('Could not file this session as a commander', {
|
|
|
|
|
sessionId,
|
|
|
|
|
transcriptId: filedId,
|
|
|
|
|
error: err instanceof Error ? err.message : String(err),
|
|
|
|
|
});
|
|
|
|
|
void setCommanderSessionId(userId, commanderId, transcriptId).catch((err: unknown) => {
|
|
|
|
|
logger.warn('Could not point this commander at its transcript', {
|
|
|
|
|
sessionId,
|
|
|
|
|
transcriptId,
|
|
|
|
|
commanderId,
|
|
|
|
|
error: err instanceof Error ? err.message : String(err),
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return (msg: TurnMessage, seq?: number): void => {
|
|
|
|
@@ -573,9 +554,6 @@ function createMessageHandler(
|
|
|
|
|
record();
|
|
|
|
|
fileAsCommander();
|
|
|
|
|
}
|
|
|
|
|
// And retried on every result until the name lands. The first attempt runs before the transcript file
|
|
|
|
|
// exists, so the end of the first turn is the earliest a rename can succeed.
|
|
|
|
|
if (msg.type === 'result' && commandHq && !commanderNamed) fileAsCommander();
|
|
|
|
|
|
|
|
|
|
/*
|
|
|
|
|
The watcher loop — see `docs/command-hq.md` → "The watcher loop" and `handoffContract` in
|
|
|
|
@@ -791,11 +769,6 @@ async function handleChat(
|
|
|
|
|
msg: {
|
|
|
|
|
prompt: string;
|
|
|
|
|
displayText?: string;
|
|
|
|
|
/**
|
|
|
|
|
* The owner pressed Start instead of typing an opener — see `commanderBlock`. Carried on the message
|
|
|
|
|
* rather than derived, because the only thing that can know it is the client that rendered the button.
|
|
|
|
|
*/
|
|
|
|
|
selfStarted?: boolean;
|
|
|
|
|
sessionId?: string;
|
|
|
|
|
model?: string;
|
|
|
|
|
cwd?: string;
|
|
|
|
@@ -931,14 +904,6 @@ export function sessionKeyForTranscript(transcriptId: string): string | undefine
|
|
|
|
|
return undefined;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Sessions already told what they are and where to record it.
|
|
|
|
|
*
|
|
|
|
|
* In memory, keyed on officer's session id: a restart re-briefs, which costs a few hundred tokens on one
|
|
|
|
|
* turn and is the safe direction — a commander that was never told is one that cannot be stopped.
|
|
|
|
|
*/
|
|
|
|
|
const briefedSessions = new Set<string>();
|
|
|
|
|
|
|
|
|
|
export async function startClaudeTurn(params: {
|
|
|
|
|
email: string;
|
|
|
|
|
username: string;
|
|
|
|
@@ -949,11 +914,6 @@ export async function startClaudeTurn(params: {
|
|
|
|
|
msg: {
|
|
|
|
|
prompt: string;
|
|
|
|
|
displayText?: string;
|
|
|
|
|
/**
|
|
|
|
|
* The owner pressed Start instead of typing an opener — see `commanderBlock`. Carried on the message
|
|
|
|
|
* rather than derived, because the only thing that can know it is the client that rendered the button.
|
|
|
|
|
*/
|
|
|
|
|
selfStarted?: boolean;
|
|
|
|
|
groupSlug?: string;
|
|
|
|
|
context?: string;
|
|
|
|
|
contextId?: string;
|
|
|
|
@@ -962,8 +922,8 @@ export async function startClaudeTurn(params: {
|
|
|
|
|
images?: PromptImage[];
|
|
|
|
|
resumeSessionId?: string;
|
|
|
|
|
planMode?: boolean;
|
|
|
|
|
/** See `commanderName` on the wire type — the name chosen before a commander's first message. */
|
|
|
|
|
commanderName?: string;
|
|
|
|
|
/** See `commanderId` on the wire type — the row `POST /commanders` already created for this turn. */
|
|
|
|
|
commanderId?: number;
|
|
|
|
|
armWatcher?: boolean;
|
|
|
|
|
};
|
|
|
|
|
effectivePrompt: string;
|
|
|
|
@@ -972,34 +932,14 @@ export async function startClaudeTurn(params: {
|
|
|
|
|
const { email, username, userId, ws, sessionId, model, msg, onOutcome } = params;
|
|
|
|
|
|
|
|
|
|
/*
|
|
|
|
|
A commander is told its record paths ON A TURN, not in the system preamble.
|
|
|
|
|
|
|
|
|
|
The preamble is built inside `createSession`, which runs ONCE — on a conversation's first turn. For a
|
|
|
|
|
commander that is precisely the turn where the block cannot exist: it has no `command_hq_sessions` row
|
|
|
|
|
and no transcript id yet, so `commanderBlock` returns null, and every later turn reuses the same session
|
|
|
|
|
and never rebuilds the preamble. So a commander never received the block at all, and its loop had no
|
|
|
|
|
paths to record itself at. Its own test caught it: it armed a perfectly good watcher and wrote no
|
|
|
|
|
`loop-pid`.
|
|
|
|
|
|
|
|
|
|
Riding `effectivePrompt` instead. That is the string the model sees; `displayText` is what the transcript
|
|
|
|
|
shows — the same seam the background-job dialog already uses to send a hidden preamble — so this reaches
|
|
|
|
|
the agent without appearing in the conversation.
|
|
|
|
|
|
|
|
|
|
Once per session, on the first turn where the block resolves, which is the first turn after the commander
|
|
|
|
|
is filed. It stays available afterwards because it is in the conversation's own history from then on.
|
|
|
|
|
Unlike before `docs/commander-directory.md`, nothing is injected into the prompt here for a commander.
|
|
|
|
|
A commander's own `CLAUDE.md` — written by `POST /commanders`, before this turn ever started — covers
|
|
|
|
|
what a per-turn block used to have to compile on the fly: its name, its reference path, and the paths
|
|
|
|
|
it must record itself at. Claude Code reads a `CLAUDE.md` from the working directory at session start,
|
|
|
|
|
so a commander told about itself in the FILE needs no per-turn injection, no `effectivePrompt` splice,
|
|
|
|
|
and no first-turn special case — see the deleted `commander-block.ts` for what this replaced and why.
|
|
|
|
|
*/
|
|
|
|
|
let effectivePrompt = params.effectivePrompt;
|
|
|
|
|
if (!briefedSessions.has(sessionId)) {
|
|
|
|
|
const block = await commanderBlock({
|
|
|
|
|
userId,
|
|
|
|
|
sessionId: transcriptIdBySessionKey.get(sessionId) ?? msg.resumeSessionId ?? null,
|
|
|
|
|
selfStarted: msg.selfStarted === true,
|
|
|
|
|
});
|
|
|
|
|
if (block) {
|
|
|
|
|
briefedSessions.add(sessionId);
|
|
|
|
|
effectivePrompt = `${block}\n\n---\n\n${effectivePrompt}`;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
const effectivePrompt = params.effectivePrompt;
|
|
|
|
|
const fail = (error: string) => {
|
|
|
|
|
sendToClient(ws, { type: 'error', message: error });
|
|
|
|
|
onOutcome?.({ ok: false, error });
|
|
|
|
@@ -1083,7 +1023,7 @@ export async function startClaudeTurn(params: {
|
|
|
|
|
msg.resumeSessionId,
|
|
|
|
|
msg.planMode,
|
|
|
|
|
msg.context === 'command-hq',
|
|
|
|
|
msg.commanderName,
|
|
|
|
|
msg.commanderId,
|
|
|
|
|
);
|
|
|
|
|
// 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
|
|
|
|
@@ -1161,11 +1101,6 @@ async function handleClaudeCodeChat(
|
|
|
|
|
msg: {
|
|
|
|
|
prompt: string;
|
|
|
|
|
displayText?: string;
|
|
|
|
|
/**
|
|
|
|
|
* The owner pressed Start instead of typing an opener — see `commanderBlock`. Carried on the message
|
|
|
|
|
* rather than derived, because the only thing that can know it is the client that rendered the button.
|
|
|
|
|
*/
|
|
|
|
|
selfStarted?: boolean;
|
|
|
|
|
groupSlug?: string;
|
|
|
|
|
context?: string;
|
|
|
|
|
contextId?: string;
|
|
|
|
@@ -1189,11 +1124,6 @@ async function handleOpenCodeChat(
|
|
|
|
|
msg: {
|
|
|
|
|
prompt: string;
|
|
|
|
|
displayText?: string;
|
|
|
|
|
/**
|
|
|
|
|
* The owner pressed Start instead of typing an opener — see `commanderBlock`. Carried on the message
|
|
|
|
|
* rather than derived, because the only thing that can know it is the client that rendered the button.
|
|
|
|
|
*/
|
|
|
|
|
selfStarted?: boolean;
|
|
|
|
|
groupSlug?: string;
|
|
|
|
|
context?: string;
|
|
|
|
|
contextId?: string;
|
|
|
|
|