host caught that resolveMemberRun failed open. Returning undefined means "run as the server owner" downstream — their binary, their ~/.claude credential, their HOME, their MCP config carrying OFFICER_AUTH_TOKEN — and three different inputs produced it: the caller being the owner, resolveHomeDir failing, and a member whose osUser is null. The last two mean "could not determine", and answering them with the owner's identity is the single thing this feature exists to prevent. 23's own comment said the caller must not fall back to the owner. The code did exactly that. The prose was right. Now a discriminated TurnIdentity: owner, member, or refuse-with-a-reason. The call site ends the turn on refuse instead of spawning. The owner's identity is reachable only by positively establishing isOwner, never by failing to establish anything else — resolveHomeDir already reported it as a positive fact and the funnel through undefined was the only thing discarding it. The null-osUser case is not hypothetical: provisionOsAccount is non-fatal at every stage and records the account either way, as its own source says. Tonight provisioning failed three separate ways on a real member and the account survived each time. No test yet, and the reason is in COMMS rather than hidden: it needs database fakes this repo has no pattern for, and inventing one at 01:00 to cover four branches is how the next defect gets written. The union is exhaustive, so tsgo catches a missing case — not the same thing, not nothing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
922 lines
38 KiB
TypeScript
922 lines
38 KiB
TypeScript
import type { ServerWebSocket } from 'bun';
|
|
import { randomUUID } from 'crypto';
|
|
import type {
|
|
ClientMessage,
|
|
ServerMessage,
|
|
Message,
|
|
PromptImage,
|
|
RunningTask,
|
|
TurnMessage,
|
|
UserSession,
|
|
} from './types';
|
|
import { sessionManager } from './session-manager';
|
|
import { rememberOpenCodePrompt } from './opencode/state';
|
|
import { sendClaudeCodeStreaming } from '@@/channels/send-claude-code';
|
|
import { sendOpenCodeStreaming } from '@@/channels/send-opencode';
|
|
import { ensureGeneralChatSessionsCwd } from './claude-sessions';
|
|
import * as sidecar from '@@/sidecar-registry';
|
|
import { join } from 'path';
|
|
import { getOwnerHomeDir, getEmailAccountsDir } from '../../../servers/data-path';
|
|
import { getUserSettings, getEmailAccounts, getChatEventsSince, appendChatEvent, getUserById } from 'officerdb';
|
|
import { resolveHomeDir } from '@@/user-home';
|
|
import { mkdirSync } from 'node:fs';
|
|
import { logger } from './logger';
|
|
|
|
// Default model when no user preference is set
|
|
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.
|
|
const isClaudeModel = (model: string): boolean => model.startsWith('claude-code');
|
|
|
|
/**
|
|
* Whose identity a turn runs as.
|
|
*
|
|
* A three-way answer rather than a nullable one, because `undefined` downstream means **the owner** — their
|
|
* binary, their `~/.claude` credential, their HOME, and their MCP config carrying `OFFICER_AUTH_TOKEN`. A
|
|
* nullable return collapsed three inputs into that: the caller genuinely being the owner, `resolveHomeDir`
|
|
* failing, and a member whose `osUser` is null. The last two mean "I could not determine whose this is", and
|
|
* answering them with the owner's identity is the one wrong answer this whole feature exists to prevent.
|
|
*
|
|
* Case three is not hypothetical: `provisionOsAccount` is non-fatal at every stage and records the account
|
|
* either way, so a member whose Linux provisioning failed exists as a row with no `osUser`. On the night this
|
|
* was written, provisioning failed three separate ways on a real member while the account continued to exist.
|
|
*
|
|
* The property: the owner's identity is reachable only by positively establishing that the caller IS the
|
|
* owner, never by failing to establish anything else. `resolveHomeDir` already reports `isOwner` as a positive
|
|
* fact — the old funnel through `undefined` was the only thing throwing it away.
|
|
*/
|
|
type TurnIdentity =
|
|
| { kind: 'owner' }
|
|
| { kind: 'member'; run: { osUser: string; home: string } }
|
|
| { kind: 'refuse'; reason: string };
|
|
|
|
async function resolveTurnIdentity(userId: number): Promise<TurnIdentity> {
|
|
const resolved = await resolveHomeDir(userId);
|
|
if (!resolved.ok) return { kind: 'refuse', reason: resolved.reason };
|
|
if (resolved.isOwner) return { kind: 'owner' };
|
|
|
|
const row = await getUserById(userId);
|
|
if (!row?.osUser) {
|
|
return { kind: 'refuse', reason: 'your Linux account is not provisioned yet, so an agent cannot run as you' };
|
|
}
|
|
return { kind: 'member', run: { osUser: row.osUser, home: resolved.home } };
|
|
}
|
|
|
|
async function getUserDefaultModel(userId: number): Promise<string | null> {
|
|
try {
|
|
const settings = await getUserSettings(userId);
|
|
const chat = settings?.chat as Record<string, unknown> | undefined;
|
|
return (chat?.defaultModel as string) || null;
|
|
} catch (err) {
|
|
logger.error('Failed to read user settings for default model', { userId, error: String(err) });
|
|
}
|
|
return null;
|
|
}
|
|
|
|
type WSData = {
|
|
userId: number;
|
|
email: string;
|
|
username: string;
|
|
provider: string;
|
|
};
|
|
|
|
const IDLE_TIMEOUT_MS = 60 * 60 * 1000; // 1 hour
|
|
|
|
const resolveCwd = (email: string, cwd?: string) => {
|
|
const root = getOwnerHomeDir(email);
|
|
if (!cwd || cwd === '~') return root;
|
|
if (cwd.startsWith('~/')) return join(root, cwd.slice(2));
|
|
// The server owner is the only account — absolute paths are theirs to use.
|
|
if (cwd.startsWith('/')) return cwd;
|
|
return join(root, cwd);
|
|
};
|
|
|
|
export const resolveBaseCwd = (email: string, cwd?: string) => resolveCwd(email, cwd);
|
|
|
|
// The email chat runs from the selected account's storage dir:
|
|
// DATA_PATH/<owner>/email_accounts/<accountEmail>
|
|
// `accountEmail` will come from the account selector (msg.contextId) later; for now default to the
|
|
// owner's first enabled account. Falls back to the email_accounts root if there are no accounts.
|
|
async function resolveEmailCwd(userId: number, ownerEmail: string, accountEmail?: string): Promise<string> {
|
|
let account = accountEmail?.trim();
|
|
if (!account) {
|
|
try {
|
|
const accounts = await getEmailAccounts(userId);
|
|
account = (accounts.find((a) => a.enabled) ?? accounts[0])?.email;
|
|
} catch (err) {
|
|
logger.error('Failed to resolve email account for chat cwd', { userId, error: String(err) });
|
|
}
|
|
}
|
|
const dir = account ? join(getEmailAccountsDir(ownerEmail), account) : getEmailAccountsDir(ownerEmail);
|
|
mkdirSync(dir, { recursive: true });
|
|
return dir;
|
|
}
|
|
|
|
// The working directory a chat turn runs in, by context: email → the account dir; /chat → a chosen
|
|
// pwd or the default general_chat_sessions dir; everything else (browser/project/dashboard) → the given cwd.
|
|
async function resolveChatCwd(
|
|
msg: { context?: string; contextId?: string; cwd?: string },
|
|
email: string,
|
|
userId: number,
|
|
): Promise<string> {
|
|
if (msg.context === 'email') return resolveEmailCwd(userId, email, msg.contextId);
|
|
if (msg.context === 'chat') return msg.cwd?.trim() ? resolveCwd(email, msg.cwd) : ensureGeneralChatSessionsCwd(email);
|
|
return resolveCwd(email, msg.cwd);
|
|
}
|
|
|
|
const wsToSessionMap = new WeakMap<any, string>();
|
|
|
|
// Per-connection heartbeat. Bun closes a WS idle for `idleTimeout` (60s), and its timer only resets
|
|
// on frames *received* from the client — but during a chat turn the client only receives. So we ping
|
|
// each connection every 25s; the client auto-pongs at the protocol level, which resets Bun's timer
|
|
// (and keeps reverse proxies happy). Genuinely dead sockets still time out (no pong).
|
|
const pingTimers = new WeakMap<ServerWebSocket<WSData>, ReturnType<typeof setInterval>>();
|
|
const PING_INTERVAL_MS = 25_000;
|
|
|
|
function sendToClient(ws: ServerWebSocket<WSData> | null, msg: ServerMessage, seq?: number): void {
|
|
if (ws?.readyState === 1) {
|
|
ws.send(JSON.stringify(seq === undefined ? msg : { ...msg, seq }));
|
|
}
|
|
}
|
|
|
|
// Nothing in officer writes to chat_session_events any more. Both harnesses commit their own turn
|
|
// output in the sidecar that produced it (`sidecar/claude/session-log.ts`), which is the whole point:
|
|
// the durable record does not travel over the socket between the two processes, so officer can restart
|
|
// mid-turn without losing it. Officer reads the table on `resume` (getChatEventsSince) and relays what
|
|
// the sidecars send. The old `emitToSession` used to live here.
|
|
|
|
export async function open(ws: ServerWebSocket<WSData>): Promise<void> {
|
|
const timer = setInterval(() => {
|
|
try {
|
|
ws.ping(); // client auto-pongs → resets Bun's idleTimeout
|
|
} catch {
|
|
/* socket already gone */
|
|
}
|
|
}, PING_INTERVAL_MS);
|
|
pingTimers.set(ws, timer);
|
|
}
|
|
|
|
export function message(ws: ServerWebSocket<WSData>, raw: string | Buffer): void {
|
|
const data = typeof raw === 'string' ? raw : raw.toString();
|
|
|
|
(async () => {
|
|
try {
|
|
const clientMsg = JSON.parse(data) as ClientMessage;
|
|
|
|
if (clientMsg.type === 'chat') {
|
|
await handleChat(ws, clientMsg);
|
|
} else if (clientMsg.type === 'resume') {
|
|
await handleResume(ws, clientMsg);
|
|
} else if (clientMsg.type === 'stop') {
|
|
await handleStop(ws);
|
|
} else if (clientMsg.type === 'disconnect') {
|
|
await handleDisconnect(ws);
|
|
} else if (clientMsg.type === 'resume-cursor') {
|
|
await handleResumeCursor(ws, clientMsg);
|
|
} else if (clientMsg.type === 'attach') {
|
|
await handleAttach(ws, clientMsg);
|
|
}
|
|
} catch (err) {
|
|
logger.error('Error handling WebSocket message', { email: ws.data.email, error: String(err) });
|
|
sendToClient(ws, { type: 'error', message: 'Failed to process message' });
|
|
}
|
|
})();
|
|
}
|
|
|
|
export function close(ws: ServerWebSocket<WSData>): void {
|
|
const timer = pingTimers.get(ws);
|
|
if (timer) {
|
|
clearInterval(timer);
|
|
pingTimers.delete(ws);
|
|
}
|
|
|
|
const sessionId = wsToSessionMap.get(ws);
|
|
if (sessionId) {
|
|
sessionManager.detachWs(sessionId, ws);
|
|
// Only once nothing is watching. Another pane or another device still attached means the
|
|
// conversation is live, and arming the idle GC here would collect it out from under them.
|
|
if (!sessionManager.hasSockets(sessionId)) sessionManager.setIdleTimeout(sessionId, IDLE_TIMEOUT_MS);
|
|
}
|
|
}
|
|
|
|
// ── Claude Code: relay, don't rebuild ──
|
|
|
|
// The agent sidecar owns Claude's turn output end to end — it translates the parser stream, commits each
|
|
// message to chat_session_events and hands us a finished message plus its cursor id. Officer relays it.
|
|
// That is what makes a restart survivable: the durable record no longer travels over the socket between
|
|
// the two processes, so if this one is down the output is already written and the client replays it.
|
|
//
|
|
// Officer keeps only the in-memory transcript, which exists to answer a `resume` with sync:messages —
|
|
// Claude's own transcript is the real record — so it is folded from the same messages, not rebuilt.
|
|
function foldIntoSession(session: UserSession, msg: TurnMessage, model: string): void {
|
|
switch (msg.type) {
|
|
case 'assistant:delta':
|
|
// A subagent's deltas are not the main agent typing; appending them here spliced its sentences into
|
|
// whatever the agent you are talking to was mid-way through saying.
|
|
if (!msg.parentToolUseId) session.streamBuffer += msg.text;
|
|
break;
|
|
|
|
case 'assistant:text':
|
|
session.messages.push({
|
|
id: randomUUID(),
|
|
timestamp: Date.now(),
|
|
role: 'assistant',
|
|
text: msg.text,
|
|
model,
|
|
parentToolUseId: msg.parentToolUseId,
|
|
});
|
|
session.meta.messageCount += 1;
|
|
// Only the main agent's own stream feeds the buffer a resume replays as `streamingText`.
|
|
if (!msg.parentToolUseId) session.streamBuffer = '';
|
|
break;
|
|
|
|
case 'tool:start':
|
|
session.messages.push({
|
|
id: randomUUID(),
|
|
timestamp: Date.now(),
|
|
role: 'tool',
|
|
toolCallId: msg.toolCallId,
|
|
toolName: msg.toolName,
|
|
toolInput: msg.toolInput,
|
|
parentToolUseId: msg.parentToolUseId,
|
|
});
|
|
session.meta.messageCount += 1;
|
|
break;
|
|
|
|
case 'tool:result':
|
|
for (let i = session.messages.length - 1; i >= 0; i--) {
|
|
const m = session.messages[i]!;
|
|
if (m.role === 'tool' && m.toolCallId === msg.toolCallId) {
|
|
m.output = msg.output;
|
|
m.isError = msg.isError;
|
|
break;
|
|
}
|
|
}
|
|
break;
|
|
|
|
case 'result': {
|
|
session.isGenerating = false;
|
|
session.meta.cost.inputTokens += msg.cost.inputTokens;
|
|
session.meta.cost.outputTokens += msg.cost.outputTokens;
|
|
session.meta.cost.totalUSD += msg.cost.totalUSD;
|
|
session.meta.updatedAt = Date.now();
|
|
// The turn's cost belongs to the assistant message it paid for (as it did when officer built these).
|
|
// The turn's cost is the main agent's, so skip past any subagent tail.
|
|
const last = [...session.messages].reverse().find((m) => !m.parentToolUseId);
|
|
if (last?.role === 'assistant' && !last.cost) last.cost = msg.cost;
|
|
break;
|
|
}
|
|
|
|
case 'error':
|
|
case 'stopped':
|
|
session.isGenerating = false;
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Shared by both harnesses now: whichever sidecar ran the turn has already translated and committed it.
|
|
function createMessageHandler(sessionId: string, model: string) {
|
|
return (msg: TurnMessage, seq?: number): void => {
|
|
const session = sessionManager.getSession(sessionId);
|
|
if (!session) return;
|
|
foldIntoSession(session, msg, model);
|
|
for (const socket of session.sockets) sendToClient(socket as ServerWebSocket<WSData>, msg, seq);
|
|
};
|
|
}
|
|
|
|
async function handleChat(
|
|
ws: ServerWebSocket<WSData>,
|
|
msg: {
|
|
prompt: string;
|
|
displayText?: string;
|
|
sessionId?: string;
|
|
model?: string;
|
|
cwd?: string;
|
|
cwdRoot?: string;
|
|
groupSlug?: string;
|
|
attachmentIds?: string[];
|
|
images?: PromptImage[];
|
|
thinking?: string;
|
|
context?: string;
|
|
contextId?: string;
|
|
resumeSummary?: string;
|
|
resumeSessionId?: string;
|
|
},
|
|
): Promise<void> {
|
|
const { userId } = ws.data;
|
|
const sessionId = msg.sessionId || randomUUID();
|
|
|
|
// Prepend resume summary to the prompt if present
|
|
const prompt = msg.resumeSummary
|
|
? `Here is a summary of a previous conversation to continue from:\n\n${msg.resumeSummary}\n\n---\n\nUser's new message: ${msg.prompt}`
|
|
: msg.prompt;
|
|
|
|
// Use provided model, or fall back to user default, or the system default.
|
|
const model = msg.model || (await getUserDefaultModel(userId)) || DEFAULT_MODEL;
|
|
|
|
logger.info('Model selected for chat', { sessionId, model, clientModel: msg.model || null });
|
|
|
|
// Route by harness: claude-code → Claude sidecar; anything else → OpenCode server.
|
|
return isClaudeModel(model)
|
|
? handleClaudeCodeChat(ws, sessionId, model, msg, prompt)
|
|
: handleOpenCodeChat(ws, sessionId, model, msg, prompt);
|
|
}
|
|
|
|
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;
|
|
},
|
|
effectivePrompt: string,
|
|
): Promise<void> {
|
|
const { email, username, userId } = ws.data;
|
|
|
|
const cwd = await resolveChatCwd(msg, email, userId);
|
|
|
|
const groupSlug = msg.groupSlug || null;
|
|
|
|
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);
|
|
|
|
sendToClient(ws, {
|
|
type: 'session:init',
|
|
sessionId,
|
|
model,
|
|
cwd,
|
|
context: session.meta.context,
|
|
contextId: session.meta.contextId,
|
|
});
|
|
|
|
// Add user message to session
|
|
const userMsg: Message = {
|
|
id: randomUUID(),
|
|
timestamp: Date.now(),
|
|
role: 'user',
|
|
text: msg.prompt,
|
|
};
|
|
session.messages.push(userMsg);
|
|
session.meta.messageCount += 1;
|
|
session.meta.updatedAt = Date.now();
|
|
|
|
if (!session.meta.title) {
|
|
session.meta.title = (msg.displayText ?? msg.prompt).slice(0, 100);
|
|
}
|
|
|
|
session.isGenerating = true;
|
|
|
|
const onMessage = createMessageHandler(sessionId, model);
|
|
|
|
try {
|
|
// Whose account this turn runs as, resolved from the authenticated socket and never from the client
|
|
// message — the same rule `server.tsx` applies to the pty sidecar. A `refuse` ends the turn here rather
|
|
// than spawning anything, because the alternative to knowing is not "assume the owner".
|
|
const identity = await resolveTurnIdentity(userId);
|
|
if (identity.kind === 'refuse') {
|
|
sendToClient(ws, { type: 'error', message: identity.reason });
|
|
session.isGenerating = false;
|
|
return;
|
|
}
|
|
|
|
if (!session._claudeKill) {
|
|
// First turn of this session: open the persistent session + a SESSION-scoped event subscription
|
|
// (survives turn-end so background task:notifications keep flowing). `kill` tears both down for an
|
|
// explicit disconnect; `detach` drops only the listener, which is what the idle GC uses so an
|
|
// absent browser stops taking a live agent with it.
|
|
const handle = await sendClaudeCodeStreaming({
|
|
userId,
|
|
email,
|
|
username,
|
|
prompt: effectivePrompt,
|
|
images: msg.images,
|
|
sessionKey: sessionId,
|
|
cwd,
|
|
model,
|
|
member: identity.kind === 'member' ? identity.run : undefined,
|
|
resumeSessionId: msg.resumeSessionId,
|
|
onMessage,
|
|
});
|
|
session.piProcess = sessionId as any;
|
|
session._claudeKill = handle.kill;
|
|
session._sidecarUnsub = handle.detach;
|
|
} else {
|
|
// Session already live: push this turn onto the existing persistent session (no new subscription).
|
|
await sidecar.spawnClaudeStreaming({
|
|
userId,
|
|
email,
|
|
username,
|
|
prompt: effectivePrompt,
|
|
images: msg.images,
|
|
sessionKey: sessionId,
|
|
cwd,
|
|
model,
|
|
resumeSessionId: msg.resumeSessionId,
|
|
});
|
|
}
|
|
} 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;
|
|
}
|
|
}
|
|
|
|
async function handleOpenCodeChat(
|
|
ws: ServerWebSocket<WSData>,
|
|
sessionId: string,
|
|
model: string,
|
|
msg: {
|
|
prompt: string;
|
|
displayText?: string;
|
|
groupSlug?: string;
|
|
context?: string;
|
|
contextId?: string;
|
|
cwd?: string;
|
|
cwdRoot?: string;
|
|
resumeSessionId?: string;
|
|
// The whole of B4 lived in this omission. The browser sent images, the bubble rendered them, and
|
|
// they stopped at this signature — so they were never passed on and never reached the model, with
|
|
// nothing anywhere reporting a loss.
|
|
images?: PromptImage[];
|
|
},
|
|
effectivePrompt: string,
|
|
): Promise<void> {
|
|
const { email, username, userId } = ws.data;
|
|
|
|
const cwd = await resolveChatCwd(msg, email, userId);
|
|
|
|
// Names this session in the Live panel until OpenCode gets round to titling it. First turn only.
|
|
rememberOpenCodePrompt(sessionId, msg.displayText || msg.prompt);
|
|
|
|
const groupSlug = msg.groupSlug || null;
|
|
|
|
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);
|
|
|
|
sendToClient(ws, {
|
|
type: 'session:init',
|
|
sessionId,
|
|
model,
|
|
cwd,
|
|
context: session.meta.context,
|
|
contextId: session.meta.contextId,
|
|
});
|
|
|
|
const userMsg: Message = {
|
|
id: randomUUID(),
|
|
timestamp: Date.now(),
|
|
role: 'user',
|
|
text: msg.prompt,
|
|
};
|
|
session.messages.push(userMsg);
|
|
session.meta.messageCount += 1;
|
|
session.meta.updatedAt = Date.now();
|
|
|
|
if (!session.meta.title) {
|
|
session.meta.title = (msg.displayText ?? msg.prompt).slice(0, 100);
|
|
}
|
|
|
|
session.isGenerating = true;
|
|
|
|
const onMessage = createMessageHandler(sessionId, model);
|
|
|
|
try {
|
|
// Drop the PREVIOUS turn's listener before opening the next one.
|
|
//
|
|
// This deliberately does not mirror the Claude guard above. Claude keeps one persistent session and
|
|
// skips re-subscribing; OpenCode runs a fresh `opencode run` subprocess per turn, so a new
|
|
// subscription each time is correct. What was wrong is that the old handle was overwritten without
|
|
// being detached, leaving the previous session-scoped listener attached — so every turn after the
|
|
// first delivered doubled, tripled, and so on, for any termination that is not result/error/stopped.
|
|
session._sidecarUnsub?.();
|
|
session._sidecarUnsub = undefined;
|
|
|
|
const handle = await sendOpenCodeStreaming({
|
|
userId,
|
|
email,
|
|
username,
|
|
prompt: effectivePrompt,
|
|
sessionKey: sessionId,
|
|
cwd,
|
|
model,
|
|
resumeSessionId: msg.resumeSessionId,
|
|
images: msg.images,
|
|
onMessage,
|
|
});
|
|
|
|
// Store the abort handle so handleStop can end the turn (OpenCode is aborted via this handle).
|
|
session.piProcess = sessionId as any;
|
|
session._claudeKill = handle.kill;
|
|
session._sidecarUnsub = handle.detach;
|
|
} catch (err) {
|
|
logger.error('Failed to start OpenCode streaming', { sessionId, error: String(err) });
|
|
sendToClient(ws, { type: 'error', message: 'Failed to start OpenCode' });
|
|
session.isGenerating = false;
|
|
}
|
|
}
|
|
|
|
async function handleResume(
|
|
ws: ServerWebSocket<WSData>,
|
|
msg: { sessionId: string; cwd?: string; cwdRoot?: string },
|
|
): Promise<void> {
|
|
const { sessionId } = msg;
|
|
|
|
try {
|
|
const session = sessionManager.getSession(sessionId);
|
|
|
|
if (!session) {
|
|
// Sessions live in memory for the connection's lifetime; there's no disk store to reload from.
|
|
sendToClient(ws, { type: 'error', message: 'Session not found', errorCode: 'SESSION_NOT_FOUND' });
|
|
return;
|
|
}
|
|
|
|
sessionManager.attachWs(sessionId, ws);
|
|
wsToSessionMap.set(ws as any, sessionId);
|
|
|
|
sendToClient(ws, {
|
|
type: 'session:init',
|
|
sessionId,
|
|
model: session.model,
|
|
cwd: session.cwd,
|
|
context: session.meta.context,
|
|
contextId: session.meta.contextId,
|
|
});
|
|
|
|
// Claude resumes lazily: the next chat prompt re-attaches via `--resume <sessionKey>`, so there's
|
|
// no long-lived process to spawn here — just replay the stored transcript to the client.
|
|
sendToClient(ws, {
|
|
type: 'sync:messages',
|
|
sessionId,
|
|
messages: session.messages,
|
|
isGenerating: session.isGenerating,
|
|
streamingText: session.streamBuffer,
|
|
});
|
|
|
|
logger.info('Session resumed successfully', { sessionId, messageCount: session.messages.length });
|
|
} catch (err) {
|
|
logger.error('Unexpected error in handleResume', { sessionId, error: String(err) });
|
|
sendToClient(ws, { type: 'error', message: 'Failed to resume session' });
|
|
}
|
|
}
|
|
|
|
async function handleStop(ws: ServerWebSocket<WSData>): Promise<void> {
|
|
const sessionId = wsToSessionMap.get(ws);
|
|
|
|
if (sessionId) {
|
|
const session = sessionManager.getSession(sessionId);
|
|
|
|
if (session?.piProcess) {
|
|
try {
|
|
if (isClaudeModel(session.model)) {
|
|
// Interrupt the current turn but KEEP the persistent session alive (background tasks + the
|
|
// warm worker survive). Full teardown is 'disconnect' → deleteSession → _claudeKill.
|
|
void sidecar.interruptClaude(sessionId, ws.data.userId);
|
|
logger.info('Interrupted Claude Code turn via sidecar (session stays warm)', { sessionId });
|
|
} else {
|
|
session._claudeKill?.(); // OpenCode: abort the turn via the stored handle
|
|
logger.info('Aborted OpenCode turn', { sessionId });
|
|
}
|
|
session.isGenerating = false;
|
|
} catch (err) {
|
|
logger.error('Failed to stop process', { sessionId, error: String(err) });
|
|
}
|
|
}
|
|
}
|
|
|
|
sendToClient(ws, { type: 'stopped' });
|
|
}
|
|
|
|
// Tear down the whole session (not just the current turn): deleteSession fires _claudeKill (kills any
|
|
// in-flight Claude/OpenCode turn) + _sidecarUnsub, clears the idle timer, and drops the session from the
|
|
// manager's maps. The WS stays open so the client can immediately start a fresh session.
|
|
async function handleDisconnect(ws: ServerWebSocket<WSData>): Promise<void> {
|
|
const sessionId = wsToSessionMap.get(ws);
|
|
if (sessionId) {
|
|
try {
|
|
sessionManager.deleteSession(sessionId);
|
|
wsToSessionMap.delete(ws as any);
|
|
logger.info('Disconnected chat session', { sessionId });
|
|
} catch (err) {
|
|
logger.error('Failed to disconnect session', { sessionId, error: String(err) });
|
|
}
|
|
}
|
|
sendToClient(ws, { type: 'disconnected' });
|
|
}
|
|
|
|
/**
|
|
* Re-adopt a session whose in-memory record died with the process that held it.
|
|
*
|
|
* The sidecars are PM2 peers, so `pm2 restart officer` does not touch a running turn: the agent keeps
|
|
* generating and keeps committing to chat_session_events. What the restart destroys is purely this
|
|
* process's binding to it — the session record and, critically, the session-scoped subscription that
|
|
* relays the sidecar's events to the browser. Re-creating the record is not enough on its own; without
|
|
* the subscription the client reconnects, receives its replay, and then goes silent for the rest of the
|
|
* turn, which is indistinguishable from the agent having died.
|
|
*
|
|
* Nothing is spawned here. The subscription is a local event-bus filter, so adopting a session that is
|
|
* NOT in fact still live upstream costs a listener that never fires and a record the idle GC collects.
|
|
*/
|
|
function adoptOrphanedSession(ws: ServerWebSocket<WSData>, sessionId: string, model: string, cwd: string): UserSession {
|
|
const { email, userId } = ws.data;
|
|
const session = sessionManager.getOrCreate(sessionId, email, cwd, model, null);
|
|
session.userId = userId;
|
|
session.piProcess = sessionId as any;
|
|
|
|
const onMessage = createMessageHandler(sessionId, model);
|
|
const unsubClaude = sidecar.onClaudeMessage((key, msg, seq) => {
|
|
if (key === sessionId) onMessage(msg, seq);
|
|
});
|
|
const unsubOpenCode = sidecar.onOpenCodeMessage((key, msg, seq) => {
|
|
if (key === sessionId) onMessage(msg, seq);
|
|
});
|
|
const unsub = () => {
|
|
unsubClaude();
|
|
unsubOpenCode();
|
|
};
|
|
|
|
// Must be set, and not only for teardown: handleChat treats an absent `_claudeKill` as "first turn of
|
|
// this session" and opens a *second* subscription, which would then deliver every message twice.
|
|
session._claudeKill = () => {
|
|
if (isClaudeModel(model)) sidecar.killClaude(sessionId, userId);
|
|
else sidecar.killOpenCode(sessionId);
|
|
unsub();
|
|
};
|
|
// An adopted session can idle out and be released like any other, and releasing detaches through this
|
|
// field alone. Leaving it unset would drop the record while the listener stayed subscribed — a leak
|
|
// that grows by one every time a browser adopts a session and then goes away.
|
|
session._sidecarUnsub = unsub;
|
|
|
|
logger.info('Adopted orphaned chat session after restart', { sessionId, model });
|
|
return session;
|
|
}
|
|
|
|
// Reconnect: re-bind this socket to the (possibly still-live) session and replay every durable event
|
|
// queued since the client's cursor — so a brief disconnect never loses turn output or a background
|
|
// task:notification. attachWs cancels the pending idle-GC.
|
|
async function handleResumeCursor(
|
|
ws: ServerWebSocket<WSData>,
|
|
msg: { sessionId: string; cursor: number; model?: string; cwd?: string; generating?: boolean },
|
|
): Promise<void> {
|
|
const { sessionId, cursor } = msg;
|
|
|
|
const known = sessionManager.getSession(sessionId);
|
|
const decision = decideResume(known?.model, msg.model);
|
|
|
|
if (decision.kind !== 'known') {
|
|
// Adopt on an assumption too: an unbound socket misses the turn entirely, which is worse than a
|
|
// harness guess that only ever costs us the cut-off check below.
|
|
adoptOrphanedSession(ws, sessionId, decision.model, msg.cwd ?? '');
|
|
if (decision.kind === 'assume') {
|
|
logger.warn('resume-cursor for an unknown session with no model; adopting on the default', { sessionId });
|
|
}
|
|
}
|
|
|
|
sessionManager.attachWs(sessionId, ws);
|
|
wsToSessionMap.set(ws as any, sessionId);
|
|
try {
|
|
const events = await getChatEventsSince(sessionId, cursor ?? 0);
|
|
for (const { id, event } of events) {
|
|
sendToClient(ws, event as ServerMessage, id);
|
|
}
|
|
} catch (err) {
|
|
logger.error('Failed to replay chat events on resume-cursor', { sessionId, error: String(err) });
|
|
}
|
|
// Only ask when the harness is actually known — never on an assumption. This check writes a DURABLE
|
|
// row, so a wrong answer is permanent: that was B7, where a defaulted `claude-code` made officer ask
|
|
// the Claude sidecar about an OpenCode session, hear "not generating", and write "the agent went
|
|
// away" into a turn that was running perfectly well.
|
|
if (msg.generating && decision.kind !== 'assume') {
|
|
await endTurnIfAgentIsGone([ws], sessionId, decision.model, ws.data.userId);
|
|
}
|
|
}
|
|
|
|
export type ResumeDecision =
|
|
| { kind: 'known'; model: string }
|
|
| { kind: 'adopt'; model: string }
|
|
| { kind: 'assume'; model: string };
|
|
|
|
/**
|
|
* Which harness a resuming session belongs to, and what that permits.
|
|
*
|
|
* This used to be `msg.model || DEFAULT_MODEL`, and `DEFAULT_MODEL` is `claude-code`. So a resume-cursor
|
|
* that omitted `model` declared every session — OpenCode ones included — to be Claude, with three
|
|
* consequences that all read as something else:
|
|
*
|
|
* - **Adopted into the wrong harness.** `adoptOrphanedSession` subscribes to the sidecar bus for that
|
|
* model and pins `session.model` for the rest of its life, so an OpenCode turn's output never
|
|
* arrived, and stopping it called `killClaude` on a key that sidecar had never held — a dead stop
|
|
* button, silently.
|
|
* - **A durable false `cut-off`.** `endTurnIfAgentIsGone` asked the Claude sidecar whether it was
|
|
* generating, was told `false` because it had never heard of the session, and wrote "the agent went
|
|
* away" into a turn that was running normally. It survives reload, which is the whole point of
|
|
* writing it durably, and is therefore unrecoverable from the UI.
|
|
* - Masked, never fixed, by the client always happening to send `model` next to `sessionId`.
|
|
*
|
|
* Two rules. **The server's own record beats the client's claim** — a session in memory already knows its
|
|
* harness, and letting a socket re-declare it is how the wrong sidecar gets a session in the first place.
|
|
* **A guess is still a guess**: when neither knows, the session is adopted on the default so delivery
|
|
* keeps working, but it is marked `assume` and nothing durable may be written from it.
|
|
*
|
|
* The middle position — refusing to adopt at all when the harness is unknown — was tried and was WRONG,
|
|
* visibly so within the hour. `useChat.ts` sends `model` only `if (modelRef.current)`, so a reconnect
|
|
* without one is ordinary, not exotic; declining to adopt left the socket unbound to a live turn, and
|
|
* the running turn's output went nowhere. On screen: the transcript collapsed to "turn completed
|
|
* without output" and only a refresh — which rebuilds from the durable log — brought it back.
|
|
*
|
|
* So adoption is about DELIVERY and must be generous. Only the durable write needs certainty.
|
|
*/
|
|
export function decideResume(knownModel: string | undefined, claimedModel: string | undefined): ResumeDecision {
|
|
if (knownModel) return { kind: 'known', model: knownModel };
|
|
if (claimedModel) return { kind: 'adopt', model: claimedModel };
|
|
return { kind: 'assume', model: DEFAULT_MODEL };
|
|
}
|
|
|
|
/**
|
|
* Which background tasks are still outstanding, by replaying the durable log against itself.
|
|
*
|
|
* A task's whole life is two events — `task:started` and, eventually, `task:notification` with a terminal
|
|
* status — so started-minus-notified is the answer, and a Map keyed by taskId keeps the last word on each.
|
|
* There is no third event: a task the agent abandoned without notifying stays here until the log is pruned,
|
|
* which is the honest reading of the record rather than a bug to paper over.
|
|
*/
|
|
function collectRunningTasks(events: ServerMessage[]): RunningTask[] {
|
|
const running = new Map<string, RunningTask>();
|
|
for (const event of events) {
|
|
if (event.type === 'task:started') {
|
|
running.set(event.taskId, {
|
|
taskId: event.taskId,
|
|
description: event.description,
|
|
taskType: event.taskType,
|
|
});
|
|
} else if (event.type === 'task:notification') {
|
|
running.delete(event.taskId);
|
|
}
|
|
}
|
|
return [...running.values()];
|
|
}
|
|
|
|
/**
|
|
* Re-bind a socket that knows only Claude's transcript uuid.
|
|
*
|
|
* This is the refresh case, and until now it was the hole in an otherwise complete reconnect path. Every
|
|
* piece of the machinery already existed — the session survives a dropped socket, the agent keeps
|
|
* generating into it, `close` only detaches and arms an hour-long idle timer — but the browser came back
|
|
* having forgotten officer's session id, so `resume-cursor` could never fire and the output simply stopped
|
|
* arriving. The uuid in the URL is the one identifier a refresh cannot destroy; the agent's on-disk map
|
|
* turns it back into the key everything else here is written in terms of.
|
|
*
|
|
* Deliberately hands over only the live turn, never the transcript — see `sync:live`.
|
|
*/
|
|
async function handleAttach(ws: ServerWebSocket<WSData>, msg: { claudeSessionId: string }): Promise<void> {
|
|
const { claudeSessionId } = msg;
|
|
if (!claudeSessionId) return;
|
|
|
|
const sessionId = await sidecar.findClaudeSessionKey(claudeSessionId, ws.data.userId);
|
|
if (!sessionId) {
|
|
// No agent, or a transcript it has never run. Nothing is wrong: an ordinary finished conversation
|
|
// opened from history lands here every time. Stay silent and leave the socket as it was — the next
|
|
// `chat` mints a session in the usual way.
|
|
logger.info('Attach found no live session for transcript', { claudeSessionId });
|
|
return;
|
|
}
|
|
|
|
// An officer restart takes the in-memory session with it while the agent carries on, so the key can
|
|
// resolve to a session this process has never heard of. Adopting re-subscribes it to the sidecar's bus,
|
|
// which is what makes the rest of the turn arrive.
|
|
// `DEFAULT_MODEL` is right here and wrong in resume-cursor, which is worth being explicit about since
|
|
// it was just removed there: this path reached `sessionId` by asking the CLAUDE sidecar to resolve a
|
|
// `claudeSessionId`, so the harness is not a guess — only Claude could have answered. Attach is
|
|
// Claude-only by construction. If an OpenCode reattach verb ever lands, this stops being safe.
|
|
const existing = sessionManager.getSession(sessionId);
|
|
const session = existing ?? adoptOrphanedSession(ws, sessionId, DEFAULT_MODEL, '');
|
|
|
|
sessionManager.attachWs(sessionId, ws);
|
|
wsToSessionMap.set(ws as any, sessionId);
|
|
|
|
// The client learns officer's key here, so any *later* drop of this socket goes down the existing
|
|
// cursor-replay path instead of coming back through attach.
|
|
sendToClient(ws, {
|
|
type: 'session:init',
|
|
sessionId,
|
|
model: session.model,
|
|
cwd: session.cwd,
|
|
context: session.meta.context,
|
|
contextId: session.meta.contextId,
|
|
});
|
|
|
|
// `isGenerating` is officer's own belief and is only as good as this process's memory of the turn. For
|
|
// an adopted session it is a fresh record's default, so ask the agent — the same question, and for the
|
|
// same reason, as `endTurnIfAgentIsGone`.
|
|
const isGenerating = existing ? session.isGenerating : await sidecar.isClaudeGenerating(sessionId, ws.data.userId);
|
|
session.isGenerating = isGenerating;
|
|
|
|
// One read serves both answers: the head of the log is the cursor, and folding the whole log gives the
|
|
// tasks still outstanding. Reading it all is affordable because attach happens once per socket and this
|
|
// server has one user; a `getLastChatEventSeq` would only have saved a second round trip.
|
|
let cursor = 0;
|
|
let runningTasks: RunningTask[] = [];
|
|
try {
|
|
const events = await getChatEventsSince(sessionId, 0);
|
|
cursor = events.at(-1)?.id ?? 0;
|
|
runningTasks = collectRunningTasks(events.map((e) => e.event as ServerMessage));
|
|
} catch (err) {
|
|
logger.error('Failed to read the durable log on attach', { sessionId, error: String(err) });
|
|
}
|
|
|
|
sendToClient(ws, {
|
|
type: 'sync:live',
|
|
sessionId,
|
|
isGenerating,
|
|
cursor,
|
|
runningTasks,
|
|
// Whatever the agent had typed but not yet finished as a message. The transcript on disk cannot
|
|
// supply it — the harness writes an assistant message only once it is complete — so this is the one
|
|
// piece of the turn a refresh would otherwise genuinely lose.
|
|
streamingText: session.streamBuffer,
|
|
});
|
|
|
|
logger.info('Attached socket to live session by transcript id', {
|
|
sessionId,
|
|
claudeSessionId,
|
|
isGenerating,
|
|
recoveredTasks: runningTasks.length,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* The client came back still believing a turn is running. Check whether it is, and if it isn't, say so.
|
|
*
|
|
* A restart of the agent sidecar takes its persistent sessions with it, and nothing downstream notices:
|
|
* the turn simply stops emitting. The browser's socket is fine, the conversation looks alive, and the
|
|
* spinner runs forever — a refresh doesn't help either, because there is no ending in the transcript to
|
|
* read. This is the one moment we can catch it, so the answer is written durably: a reload after this
|
|
* shows the same explanation rather than a conversation that trails off mid-tool-call.
|
|
*
|
|
* Only the claude harness is asked. OpenCode runs a turn per invocation and has no equivalent question,
|
|
* so its sessions are left alone rather than guessed at.
|
|
*/
|
|
async function endTurnIfAgentIsGone(
|
|
targets: Iterable<ServerWebSocket<WSData> | null>,
|
|
sessionId: string,
|
|
model: string,
|
|
userId: number,
|
|
): Promise<void> {
|
|
if (!isClaudeModel(model)) return;
|
|
if (await sidecar.isClaudeGenerating(sessionId, userId)) return;
|
|
|
|
const session = sessionManager.getSession(sessionId);
|
|
if (session) session.isGenerating = false;
|
|
|
|
const event: ServerMessage = { type: 'cut-off' };
|
|
try {
|
|
const seq = await appendChatEvent(sessionId, event);
|
|
for (const target of targets) sendToClient(target, event, seq);
|
|
} catch (err) {
|
|
// Still tell every client — an un-replayable explanation beats a spinner that never stops.
|
|
logger.error('Failed to persist cut-off notice', { sessionId, error: String(err) });
|
|
for (const target of targets) sendToClient(target, event);
|
|
}
|
|
logger.info('Ended a turn whose agent had gone', { sessionId });
|
|
}
|
|
|
|
// The other half of the same problem: the agent restarts while the browser sits there with a healthy
|
|
// socket, so nothing ever reconnects and nothing ever asks. A fresh agent process means every turn we
|
|
// still believe is running belongs to a process that no longer exists. On a fresh officer this loop is
|
|
// empty — it has no sessions yet — which is exactly right, because that case is the reconnect's to catch.
|
|
sidecar.onClaudeSidecarStarted(() => {
|
|
for (const session of sessionManager.getAllSessions()) {
|
|
if (!session.isGenerating) continue;
|
|
// No owner recorded, no question asked. `isClaudeGenerating` is now scoped to a caller, and there is no
|
|
// safe id to substitute — asking as the owner would let a member's orphaned session be answered with the
|
|
// owner's authority, and asking as nobody is not a thing. Leaving it marked generating is the same
|
|
// outcome as before this loop existed, and it self-corrects on the next reconnect.
|
|
if (session.userId === undefined) continue;
|
|
void endTurnIfAgentIsGone(
|
|
session.sockets as Set<ServerWebSocket<WSData>>,
|
|
session.sessionId,
|
|
session.model,
|
|
session.userId,
|
|
);
|
|
}
|
|
});
|
|
|
|
export const chatWebsocket = {
|
|
open,
|
|
message,
|
|
close,
|
|
drain() {},
|
|
};
|