Files
platform/src/servers/sidecar/claude/claude-manager.ts
T
pastilhasandClaude Opus 5 3f071c0b24 the install root is derived, not configured
Seven variables out of .env. DATA_PATH, OFFICER_ITEMS_DIR and HOME_DIR are gone
from the code entirely; PUBLIC_URL, PUBLIC_BUILD_ENV, JWT_SECRET and
VAULT_STORE_KEY are no longer written by the setup script.

data-path.ts now derives OFFICER_ROOT as dirname(process.cwd()), with data/,
capabilities/ and dockers/ as fixed names under it. The direction used to run the
other way — DATA_PATH from env, then OFFICER_ROOT = dirname(DATA_PATH) in
app-store/paths.ts — which meant three environment variables that had to agree
with each other and with the tree on disk.

Eight files re-read process.env.DATA_PATH independently, each with its own
`?? cwd()/data` fallback. They import the one value now, which is what made
removing it safe: otherwise each would have derived its own and drifted.

Three things this turned up.

The cwd pin in ecosystem.profile.cjs was broken. It set `cwd: __dirname` under a
comment asserting "__dirname is the repo root — this file sits beside
ecosystem.config.cjs", which stopped being true when these files moved into
ecosystem-files/. It walks up to the platform's package.json now, which holds
wherever the file lives. That was a live bug before this change and a load-bearing
one after it, since cwd now decides where the install is.

assertInstallLayout joins the other two boot assertions. A wrong cwd does not
error — it computes a plausible root somewhere else and writes managed homes and
agent runs into it, so the install looks empty and the data looks lost with
nothing naming the cause. It throws before serve(), first of the three, because a
wrong answer there makes the other two check the wrong files.

getOwnerHomeDir captures homedir() once at module load rather than per call.
Measured on bun 1.3.10: both os.homedir() and os.userInfo().homedir return $HOME
when set rather than reading passwd, and user-instance.ts assigns process.env.HOME
on its way to spawning an agent. A lazy read would have returned the owner's home
on the first call and a member's afterwards. data-path.ts imports only node
builtins, so it is evaluated before any of that runs.

JWT_SECRET and VAULT_STORE_KEY leaving .env means an install made by this script
does not boot — jwt.ts throws at module load without one. That is the agreed
sequencing: they move to the SQLite store (docs/secret-store.md), and writing them
here meanwhile would create a second origin for a secret the store then has to be
reconciled with. Said plainly in .env.example and in lib/env.sh rather than left
to be discovered.

Not typechecked: node_modules is empty here and installs are frozen. Every edited
file parses under `bun build --no-bundle`; the profile loads and pins the right
cwd; assertInstallLayout was exercised from both the repo and /tmp; the setup
section was run and writes five variables. Prettier was NOT run — 3.9.6 via bunx
is not the pinned resolution and reformatted unrelated unions and line wraps in
six files, so those were reverted and the edits re-applied by hand.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 23:18:48 +00:00

591 lines
25 KiB
TypeScript

import { existsSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
import { query, type Query } from '@anthropic-ai/claude-agent-sdk';
import type { ChatEvent, PromptImage } from '../../api/chat/types';
import type { ClaudeSpawnParams, ClaudeSpawnStreamingParams, ClaudeCodeResult, LiveClaudeSession } from '../protocol';
import { setClaudeSession, clearClaudeSession, getClaudeSession } from './state';
import { createParseState, processMessage } from './stream-parser';
import { spawnClaudeAsMember } from './spawn-as-member';
import { claudeBinIn } from '@@/os-user-claude';
const SEND_TIMEOUT_MS = 30 * 60 * 1000;
// Where the Claude Code CLI lives. This was hardcoded to /usr/local/bin/claude, which dated from the
// bwrap-sandboxed architecture: the jail ro-bound /usr and saw nothing else, so the installer's real
// target (~/.local/bin/claude) had to be symlinked into a path the sandbox could reach. That sandbox
// is gone, and the hardcoded path made the sidecar unrunnable anywhere it does not exist — a stock
// macOS host has no /usr/local/bin at all.
//
// Resolution order mirrors OPENCODE_BIN in the opencode sidecar: an explicit pin, then PATH, then the
// locations Anthropic's installer actually writes to.
function resolveClaudeBin(): string {
const pinned = process.env.CLAUDE_BIN;
if (pinned) return pinned;
const onPath = Bun.which('claude');
if (onPath) return onPath;
const candidates = [join(homedir(), '.local', 'bin', 'claude'), '/usr/local/bin/claude', '/opt/homebrew/bin/claude'];
return candidates.find((candidate) => existsSync(candidate)) ?? 'claude';
}
const CLAUDE_BIN = resolveClaudeBin();
console.log(`[claude] CLI resolved to ${CLAUDE_BIN}`);
// Capture original HOME before user-instance overrides it
const HOST_HOME = process.env.HOME!;
import { DATA_PATH } from '../../data-path';
// Tear a persistent session down after this long with no new turn (see PersistentSession below).
const IDLE_TIMEOUT_MS = 30 * 60 * 1000;
// MCP config paths, set by user-instance at startup
let mcpHostPath: string | undefined; // path on the host filesystem
export function setMcpConfigPath(hostPath: string): void {
mcpHostPath = hostPath;
}
// ── Blocking send (one-shot; used by the non-streaming 'claude:spawn' command) ──
type ClaudeCodeOutput = {
result: string;
session_id: string;
cost_usd: number;
input_tokens: number;
output_tokens: number;
is_error: boolean;
};
export async function spawnClaude(params: ClaudeSpawnParams): Promise<ClaudeCodeResult> {
const { prompt, sessionKey } = params;
const existingSession = getClaudeSession(sessionKey, params.userId);
const claudeArgs = [CLAUDE_BIN, '-p', prompt, '--dangerously-skip-permissions', '--output-format', 'json'];
const mcpConfig = mcpHostPath;
if (mcpConfig) claudeArgs.push('--mcp-config', mcpConfig);
const subModel = params.model?.split('/')[1];
if (subModel) claudeArgs.push('--model', subModel);
if (existingSession) {
claudeArgs.push('--resume', existingSession);
}
const spawnCmd = claudeArgs;
const spawnCwd = params.cwd ?? HOST_HOME;
const proc = Bun.spawn(spawnCmd, {
stdin: 'pipe',
stdout: 'pipe',
stderr: 'pipe',
cwd: spawnCwd,
env: process.env as Record<string, string>,
});
const timeout = setTimeout(() => {
try {
proc.kill();
} catch {
/* already dead */
}
}, SEND_TIMEOUT_MS);
try {
const [stdout, stderr] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()]);
const exitCode = await proc.exited;
clearTimeout(timeout);
if (exitCode !== 0 && !stdout.trim()) {
throw new Error(`claude exited with code ${exitCode}: ${stderr.trim().slice(0, 200)}`);
}
let output: ClaudeCodeOutput;
try {
output = JSON.parse(stdout) as ClaudeCodeOutput;
} catch {
return {
text: stdout.trim() || '(no response)',
sessionId: sessionKey,
model: 'claude-code',
cost: { inputTokens: 0, outputTokens: 0, totalUSD: 0 },
};
}
if (output.is_error) {
throw new Error(output.result || 'Claude Code returned an error');
}
if (output.session_id) {
setClaudeSession(sessionKey, output.session_id, params.userId);
}
return {
text: output.result || '(no response)',
sessionId: sessionKey,
model: 'claude-code',
cost: {
inputTokens: output.input_tokens ?? 0,
outputTokens: output.output_tokens ?? 0,
totalUSD: output.cost_usd ?? 0,
},
};
} catch (err) {
clearTimeout(timeout);
throw err;
}
}
// ── Persistent streaming sessions (Agent SDK) ──
//
// Each sessionKey gets ONE long-lived `query()` driven by a streaming-input queue. The process stays
// alive BETWEEN turns, so background work (Bash run_in_background, Monitor) and its `task_notification`
// return to a live harness instead of being orphaned when the turn ends. A turn = one user message
// pushed onto the input queue; the single consumer loop maps every SDK message (assistant text/tools,
// tool results, turn `result`, and — crucially — post-turn `task_started`/`task_notification`) to a
// ChatEvent and forwards it. Idle policy: torn down after IDLE_TIMEOUT_MS with no new turn, or on an
// explicit kill (user "disconnect"). "Stop" is interrupt() — it ends the turn but keeps the session.
/**
* `content` is an Anthropic `MessageParam` content: a bare string, or the block array we build when the
* turn carries images. Blocks are what make an attached screenshot actually reach the model — the string
* form can only carry the client's `[Attached image: …]` placeholder, which is a label, not the picture.
*/
type ContentBlock =
| { type: 'text'; text: string }
| { type: 'image'; source: { type: 'base64'; media_type: string; data: string } };
type SdkUserMessage = {
type: 'user';
message: { role: 'user'; content: string | ContentBlock[] };
parent_tool_use_id: null;
session_id: string;
};
type PersistentSession = {
sessionKey: string;
/** Whose session this is. The map is global and `sessionKey` arrives in a client message. */
userId: number;
query: Query;
pushTurn: (prompt: string, images?: PromptImage[]) => void;
closeInput: () => void;
abort: AbortController;
emit: (event: ChatEvent) => void;
isGenerating: boolean;
pendingTasks: Set<string>; // background tasks started but not yet notified; suppress idle-GC while non-empty
/**
* The user pressed stop and we are waiting for the turn to fall over. The SDK reports an interrupt as
* an ordinary failed `result` — `is_error` with no text — which is indistinguishable downstream from
* the harness actually breaking, and reached the user as "Claude Code returned an error". Only the
* side that called `interrupt()` knows better, so it says so here.
*/
interrupted: boolean;
/**
* When the `PreCompact` hook fired, so the `compact_boundary` that closes it can carry how long the
* silence lasted. The harness reports the boundary but not the duration, and the duration is the part
* that explains the wait.
*/
compactStartedAt?: number;
idleTimer?: ReturnType<typeof setTimeout>;
/**
* Fires when a turn that claims to be generating has emitted nothing for too long.
*
* The idle timer above answers the opposite question — how long a session with NO turn in flight may
* sit before it is collected — so neither one covers a turn that is wedged. Nothing did: a turn could
* stop producing events and stay `isGenerating` forever, and every client showed a spinner with no
* timeout of its own. On 2026-08-08 that ran for seventeen minutes inside a compaction and was
* indistinguishable, from the phone, from a dead chat.
*/
stallTimer?: ReturnType<typeof setTimeout>;
};
/*
How long a generating turn may say nothing before we call it stalled.
Generous on purpose, because the legitimate silences here are long: compaction narrates nothing for
as long as it takes (minutes on a large conversation), and a single deep tool call can be quiet for a
while too. This is a backstop against turns that will never speak again, not a latency budget — too
tight and it would kill work that was about to succeed, which is worse than the hang it prevents.
*/
const STALL_TIMEOUT_MS = 10 * 60 * 1000;
/** Compaction gets longer still: it is the known-slowest silent phase, and the one that stalled. */
const COMPACT_STALL_TIMEOUT_MS = 20 * 60 * 1000;
const sessions = new Map<string, PersistentSession>();
/** A hand-rolled async iterable we can push turns onto and close on teardown. */
function makeInputQueue() {
const buf: SdkUserMessage[] = [];
let wake: (() => void) | null = null;
let closed = false;
async function* gen(): AsyncGenerator<SdkUserMessage> {
while (true) {
if (buf.length) {
yield buf.shift()!;
continue;
}
if (closed) return;
await new Promise<void>((r) => {
wake = r;
});
}
}
return {
gen: gen(),
push(m: SdkUserMessage) {
buf.push(m);
wake?.();
wake = null;
},
close() {
closed = true;
wake?.();
wake = null;
},
};
}
/**
* (Re)arm the stall watchdog. Called on every emitted event, so any sign of life pushes it back.
*
* On expiry it ends the turn the same way a real failure would — `isGenerating` off, idle re-armed,
* and an `error` the client can render — rather than tearing the session down. The agent process is
* left alive deliberately: it may still be working, and the next turn resumes it. What this guarantees
* is that the CLIENT is told, which is the part that was missing.
*/
function armStall(session: PersistentSession): void {
if (session.stallTimer) clearTimeout(session.stallTimer);
if (!session.isGenerating) {
session.stallTimer = undefined;
return;
}
const compacting = session.compactStartedAt !== undefined;
session.stallTimer = setTimeout(
() => {
if (!session.isGenerating) return;
const waited = Math.round((compacting ? COMPACT_STALL_TIMEOUT_MS : STALL_TIMEOUT_MS) / 60000);
console.error(
`[claude:stall:${session.sessionKey}] no events for ${waited}m${compacting ? ' (compacting)' : ''} — ending the turn`,
);
session.isGenerating = false;
session.compactStartedAt = undefined;
session.interrupted = false;
if (session.pendingTasks.size === 0) armIdle(session);
session.emit({
type: 'error',
message: compacting
? `Compaction has produced nothing for ${waited} minutes and has been given up on. Your conversation is safe — send again to continue.`
: `The agent has produced nothing for ${waited} minutes and has been given up on. Your conversation is safe — send again to continue.`,
});
},
compacting ? COMPACT_STALL_TIMEOUT_MS : STALL_TIMEOUT_MS,
);
}
function armIdle(session: PersistentSession): void {
if (session.idleTimer) clearTimeout(session.idleTimer);
session.idleTimer = setTimeout(() => {
// Heartbeat: never GC a session that's mid-turn or still has background tasks running — a long
// silent run_in_background job would otherwise be killed along with its pending task_notification.
if (session.isGenerating || session.pendingTasks.size > 0) {
armIdle(session);
return;
}
// The idle GC is this process acting on its own session, so it passes the session's own owner.
killClaudeSession(session.sessionKey, session.userId);
}, IDLE_TIMEOUT_MS);
}
function createSession(params: ClaudeSpawnStreamingParams, onEvent: (event: ChatEvent) => void): PersistentSession {
const { sessionKey } = params;
const input = makeInputQueue();
const abort = new AbortController();
const session: PersistentSession = {
sessionKey,
userId: params.userId,
query: undefined as unknown as Query,
pushTurn: () => {},
closeInput: () => input.close(),
abort,
emit: onEvent,
isGenerating: false,
pendingTasks: new Set<string>(),
interrupted: false,
};
// Strip the nested-session guard vars so the SDK can spawn `claude` (mirrors the old spawn env clean).
const { CLAUDECODE: _c, CLAUDE_CODE_ENTRYPOINT: _e, CLAUDE_CODE_SSE_PORT: _s, ...cleanEnv } = process.env;
const resumeId = getClaudeSession(sessionKey, params.userId) ?? params.resumeSessionId;
const subModel = params.model?.split('/')[1];
const q = query({
prompt: input.gen as AsyncIterable<SdkUserMessage>,
options: {
// HOST_HOME is this process's home — the owner's. Defaulting a member's turn to it would start them in
// a directory they cannot read, and the failure would look like a broken agent rather than a wrong cwd.
cwd: params.cwd ?? params.member?.home ?? HOST_HOME,
permissionMode: 'bypassPermissions',
allowDangerouslySkipPermissions: true,
includePartialMessages: true,
// The only warning that compaction is about to happen. Everything else the harness does narrates
// itself through the message stream; compaction goes silent for as long as it takes — 2.5 minutes
// in the worst case on disk here — and the stream resumes with no explanation of the gap. The hook
// returns immediately and never throws: it is a notification, and it must not be able to stall or
// fail the compaction it is announcing.
hooks: {
PreCompact: [
{
hooks: [
async (input) => {
session.compactStartedAt = Date.now();
const trigger = 'trigger' in input && input.trigger === 'manual' ? 'manual' : 'auto';
session.emit({ type: 'compact:start', trigger });
// Re-arm on the compaction budget: this hook fires as the long silence BEGINS, so the
// deadline the turn is holding was sized for ordinary work and is about to be wrong.
armStall(session);
return { continue: true };
},
],
},
],
},
abortController: abort,
// A member's turn runs their own install as their own Linux account; the owner's runs as it always has.
//
// `settingSources` is why the binary and the spawn have to move together: it makes `~/.claude`
// authoritative for settings, and `~` is decided by the HOME the process gets. Pointing the SDK at a
// member's binary while spawning as the service user would read the OWNER'S settings and credential
// while executing the member's code — the worst of both, and it would look like it worked.
...(params.member
? {
pathToClaudeCodeExecutable: claudeBinIn(params.member.home),
spawnClaudeCodeProcess: spawnClaudeAsMember(params.member),
}
: { pathToClaudeCodeExecutable: CLAUDE_BIN }),
settingSources: ['user', 'project', 'local'],
env: cleanEnv as Record<string, string>,
stderr: (d: string) => {
if (d.trim()) console.error(`[claude:stream:${sessionKey}] ${d.slice(0, 300)}`);
},
...(subModel ? { model: subModel } : {}),
...(resumeId ? { resume: resumeId } : {}),
// The owner's MCP config, and only ever the owner's. `mcpHostPath` is module-level, written once at
// this process's bootstrap, and its `env` carries OFFICER_AUTH_TOKEN — a JWT that signs as the owner.
// Handing it to a member's turn would either spawn their MCP server holding the owner's token, or (once
// that file is 0600, which it now is) point their `claude` at a file it cannot read and fail obscurely.
//
// So a member gets no MCP config at all. What they SHOULD get — their own generated config with a token
// scoped to them, or nothing until per-user tools exist — is an open design question; `undefined` is
// the correct answer until it is settled, and is strictly better than the owner's.
...(mcpHostPath && !params.member ? { extraArgs: { 'mcp-config': mcpHostPath } } : {}),
},
});
session.query = q;
session.pushTurn = (prompt: string, images?: PromptImage[]) => {
if (session.idleTimer) clearTimeout(session.idleTimer);
session.isGenerating = true;
// A turn that dies before its FIRST event is the case the emit-path arming cannot reach.
armStall(session);
// Images first, then the text: the model reads what it is looking at before what to do about it.
const content: string | ContentBlock[] = images?.length
? [
...images.map(
(img): ContentBlock => ({
type: 'image',
source: { type: 'base64', media_type: img.mediaType, data: img.data },
}),
),
{ type: 'text', text: prompt },
]
: prompt;
input.push({
type: 'user',
message: { role: 'user', content },
parent_tool_use_id: null,
session_id: sessionKey,
});
};
sessions.set(sessionKey, session);
// Single consumer loop for the session's whole life. Turn-end (`result`) and errors flip isGenerating
// and (re)arm the idle timer; the session process stays alive so later task_notifications still flow.
void (async () => {
const state = createParseState();
const emit = (raw: ChatEvent) => {
// A turn we interrupted ends in a failed `result`. That is the stop landing, not a fault.
const event: ChatEvent =
raw.type === 'error' && session.interrupted
? { type: 'stopped' }
: // The boundary knows what it dropped; only this side knows how long it took, because the start
// came from a hook rather than from the stream.
raw.type === 'compact:done' && session.compactStartedAt
? { ...raw, durationMs: Date.now() - session.compactStartedAt }
: raw;
if (event.type === 'compact:done') session.compactStartedAt = undefined;
if (event.type === 'task:started') {
// Work is running — hold off idle-GC until it finishes.
session.pendingTasks.add(event.taskId);
if (session.idleTimer) {
clearTimeout(session.idleTimer);
session.idleTimer = undefined;
}
} else if (event.type === 'task:notification') {
session.pendingTasks.delete(event.taskId);
if (!session.isGenerating && session.pendingTasks.size === 0) armIdle(session);
} else if (event.type === 'result' || event.type === 'error' || event.type === 'stopped') {
session.isGenerating = false;
// Whatever ended the turn, the interrupt is spent — a later genuine error must not wear it.
session.interrupted = false;
if (session.pendingTasks.size === 0) armIdle(session);
}
// Any event at all is a sign of life, so push the stall deadline back. Placed after the branch
// above so a terminal event disarms rather than re-arms it.
armStall(session);
session.emit(event);
};
try {
for await (const msg of q as AsyncGenerator<Record<string, unknown>>) {
processMessage(msg, state, {
onEvent: emit,
onSessionId: (id: string) => setClaudeSession(sessionKey, id, params.userId),
});
}
} catch (err) {
if (!abort.signal.aborted) {
session.emit({ type: 'error', message: err instanceof Error ? err.message : String(err) });
}
} finally {
if (session.idleTimer) clearTimeout(session.idleTimer);
if (session.stallTimer) clearTimeout(session.stallTimer);
sessions.delete(sessionKey);
}
})();
return session;
}
/** Start a turn: create the persistent session if needed, then push the prompt as a user message. */
export async function spawnClaudeStreaming(
params: ClaudeSpawnStreamingParams,
onEvent: (event: ChatEvent) => void,
): Promise<void> {
let session = sessions.get(params.sessionKey);
if (session && session.userId !== params.userId) {
// A live session belongs to whoever started it. Without this, handing over someone else's `sessionKey`
// pushes a turn into their conversation and streams their agent's output back — the chat equivalent of
// resuming another account's shell, which `4d4a253f` refused for the pty sidecar.
//
// Throws rather than silently starting a fresh session under the same key: the caller asked to continue a
// specific conversation, and quietly giving them a different one is its own kind of wrong.
throw new Error('that chat session belongs to another account');
}
if (session) {
session.emit = onEvent; // adopt the latest emitter (equivalent across turns; keeps events flowing)
} else {
session = createSession(params, onEvent);
}
session.pushTurn(params.prompt, params.images);
}
/** Interrupt the current turn but KEEP the session alive (the "stop" button). */
export async function interruptClaudeSession(sessionKey: string, userId: number): Promise<boolean> {
const session = ownedSession(sessionKey, userId);
if (!session) return false;
// Set before the await: the failed `result` can arrive while interrupt() is still resolving, and the
// consumer loop reads this flag to tell a stop from a fault.
session.interrupted = true;
try {
await session.query.interrupt();
session.isGenerating = false;
if (session.stallTimer) clearTimeout(session.stallTimer);
session.stallTimer = undefined;
return true;
} catch {
session.interrupted = false;
return false;
}
}
/** Fully tear the session down (the "disconnect" action / idle GC): abort the query + close input. */
export function killClaudeSession(sessionKey: string, userId: number): boolean {
const session = ownedSession(sessionKey, userId);
if (!session) return false;
if (session.idleTimer) clearTimeout(session.idleTimer);
if (session.stallTimer) clearTimeout(session.stallTimer);
try {
session.abort.abort();
} catch {
/* already aborted */
}
try {
session.closeInput();
} catch {
/* noop */
}
sessions.delete(sessionKey);
return true;
}
export function clearSession(sessionKey: string, userId: number): void {
// Only clears a mapping that is theirs. `getClaudeSession` already refuses a mismatch, so this asks it
// first rather than reimplementing the check.
if (!getClaudeSession(sessionKey, userId)) return;
clearClaudeSession(sessionKey);
}
/**
* The live session under this key, **only if it belongs to the caller**.
*
* Undefined for both "no such session" and "not yours", deliberately: every caller of this treats the two the
* same, and a distinct answer for the second would tell a guesser that a session exists under a key they do
* not own — which is the whole thing being defended against.
*/
function ownedSession(sessionKey: string, userId: number): PersistentSession | undefined {
const session = sessions.get(sessionKey);
if (!session || session.userId !== userId) return undefined;
return session;
}
/**
* Everything this process is holding, with the two facts that decide whether it is busy.
*
* Replaces a `getActiveSessionKeys` that returned bare keys and was never called by anything — the keys
* alone could not distinguish a session mid-turn from one merely open, which is the whole question a
* caller has. These are the same two fields `armIdle` consults before collecting a session.
*/
export function listSessions(userId: number): LiveClaudeSession[] {
// Filtered, not just labelled. Enumerating every live session is a disclosure on its own, before anyone
// acts on one: it names other accounts' conversations and says which are busy.
return Array.from(sessions.values())
.filter((session) => session.userId === userId)
.map((session) => ({
sessionKey: session.sessionKey,
// The only place this mapping exists. Without it a caller cannot find the transcript, because the
// key is officer's handle and the filename is Claude's id.
claudeSessionId: getClaudeSession(session.sessionKey, session.userId) ?? null,
isGenerating: session.isGenerating,
pendingTasks: session.pendingTasks.size,
}));
}
/**
* Is a turn actually in flight for this session, right now, in this process?
*
* This is the ground truth a reconnecting browser has no way to work out for itself. Officer's own view
* dies with `pm2 restart officer` while the turn keeps running here, so "officer doesn't remember" means
* nothing — and if *this* process was the one that restarted, the session is simply absent and the turn
* it was running is gone, however alive the client still believes it to be.
*/
export function isSessionGenerating(sessionKey: string, userId: number): boolean {
// "Not yours" answers the same as "no such session": false. The caller uses this to decide whether to end
// a turn it believes is running, and its own turn is the only one it can be right about.
return ownedSession(sessionKey, userId)?.isGenerating ?? false;
}