Files
platform/src/servers/api/chat/chat.ts
T
pastilhasandClaude Opus 5 73eb128fb1 pause, resume and restart a background job without asking the agent
Three buttons in the expanded task panel, beside Stop. Not on the chip: the panel
is where you decide what to do with a job, and a row of unlabelled glyphs on a pill
is where you misclick.

── Pause / Resume ──

SIGSTOP and SIGCONT — the only pair that means "stop doing things and keep
everything". The process keeps its memory, its open files and its bound port, and
picks up mid-instruction. Signals go to every pid holding the job's output file,
because for a shell job that is the shell AND what it spawned, and freezing one of
them would be worse than freezing neither.

Verified by watching a job that writes a line a second: 3 lines, frozen at 3 across
three seconds of pause, 6 after resume.

Worth knowing before pressing it: a paused server does not REFUSE connections, it
stops answering them, so a browser pointed at it hangs rather than erroring. That is
what pausing is, not a shortcoming here.

── Restart, and the cost it accepts ──

The command is recovered by joining the transcript to itself: the harness records
the `tool_use` carrying `command`, and separately its result — the text
`Command running in background with ID: <id>`. Neither alone is enough; together
they are a join on `tool_use_id`, and that is the only place the command survives.
The transcript is found FROM the output path rather than searched for, since a task
file at `<tmp>/claude-<uid>/<slug>/<session>/tasks/<id>.output` names both.

The respawned process appends to that same output file. That is what keeps
everything else working untouched — the log view reads that file, and Stop finds the
process by asking which pids hold it open.

The real cost, chosen deliberately: the platform becomes a SECOND owner of a task.
The harness stops watching when its own child dies, so the platform supervises what
it spawned and emits the same `task:started` / `task:notification` pair everything
downstream is built on. `emitToSession` is the seam, and it is the only one — nothing
else learns that a task can have two kinds of parent.

A restart re-announces an id already in the transcript, so `task:started` now RESETS
an existing task row rather than appending a second one. Without that the tray showed
the job twice, once finished and once running.

Restart is offered for a finished job too, which is the case that matters most: a dev
server that fell over is exactly what you want back without typing a sentence at an
agent that may be mid-turn.

Verified end to end against a planted task: command recovered, old process killed,
new one spawned, output APPENDED (`first run output` then `RESTARTED-OK`), exit
reported through the callback, supervision registry released afterwards.

`[open]` A restarted job does not survive an `officer` restart — the platform is its
parent now. The harness's own jobs never did either.

tsgo clean, frontend builds, 890 pass / 7 fail unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 13:26:07 +00:00

375 lines
20 KiB
TypeScript

import type { Context } from 'hono';
import { emitToSession } from './websocket';
import { pauseBackgroundTask, restartBackgroundTask } from './task-restart';
import { createRouter } from '../../create-router';
import * as errors from '@@/custom-errors';
import * as sidecar from '@@/sidecar-registry';
import { getUserSettings } from 'officerdb';
import {
listClaudePwds,
listClaudeSessions,
loadClaudeSession,
loadClaudeSessionById,
deleteClaudeSession,
renameClaudeSession,
loadBackgroundTask,
stopBackgroundTask,
claudeSessionContext,
liveSessionTitle,
} from './claude-sessions';
import {
listOpenCodeSessions,
loadOpenCodeSession,
deleteOpenCodeSession,
renameOpenCodeSession,
isOpenCodeSessionId,
} from './opencode-sessions';
import { getOpenCodePrompt, getOpenCodeSession } from './opencode/state';
import { listChatModels } from './list-models';
import { logger } from './logger';
import { resolveHomeDir } from '@@/user-home';
import type { ChatIdentity } from './claude-sessions';
import { readSttConfig } from '../server-settings/stt';
/**
* Whose transcripts a request may read.
*
* The home comes from `resolveHomeDir`, never from `getOwnerHomeDir` — that one ignores its argument and
* always answers the owner, which is how every read in this router used to resolve to the owner's
* `~/.claude` no matter who asked. Throws rather than falling back, for the same reason
* `resolveTurnIdentity` refuses: there is no
* safe home to substitute, and the owner's is the one wrong answer.
*
* Reachable by a member since 2026-08-12 — see the note below on what replaced the wholesale refusal that
* used to stand at the top of this router.
*/
async function chatIdentity(user: { id: number; email: string }): Promise<ChatIdentity> {
const resolved = await resolveHomeDir(user.id);
if (!resolved.ok) throw errors.FORBIDDEN(resolved.reason);
return { email: user.email, home: resolved.home, osUser: resolved.osUser, isOwner: resolved.isOwner };
}
// ── OpenCode is owner-only, temporarily ──
//
// The Claude harness earned its way to members: the turn runs as their Linux account, the credential and
// transcripts are theirs, and every sidecar command refuses a session belonging to someone else. NONE of that
// is true of OpenCode. One `opencode serve` runs as the SERVICE user for everyone, `sendOpenCodeStreaming`
// accepts `userId`/`email`/`username` and forwards none of them, and its session store has no per-user
// scoping at all — `loadOpenCodeSession(id)` takes an id and no identity.
//
// Two consequences, both reachable by any account holding the `chat` grant, which every role has by default:
// a turn ran in the OWNER'S home as the owner, and any session on the box could be read, renamed or deleted
// by id. `handleOpenCodeChat` carried a comment calling itself owner-only; nothing enforced it.
//
// So this is a stopgap, not a design: `who.isOwner` applied at every door below, until OpenCode carries an
// identity the way `spawnClaudeAsMember` does. Restrict here rather than at the permission layer because
// `chat` is one permission covering both harnesses, and splitting it would strand the grants already issued.
// The matching refusal on the execution path is in `websocket.ts` → `handleChat`.
import { transcribeAudio } from '../stt/transcribe';
import { registerAgentPanelRoutes } from './agent-panels-routes';
export const chatRouter = createRouter();
// ── Chat reached members on 2026-08-12 ──
//
// A wholesale `isSuperAdmin` refusal stood here from the day `chat` became grantable until tonight. It said
// the machinery was not ready, and it was right: a turn spawned `claude` as the OWNER, and every transcript
// path resolved through the owner's home, so a granted member would have read the owner's sessions and run an
// agent as them.
//
// What replaced it, rather than what deleted it:
//
// - the turn runs as the member — `spawnClaudeAsMember` through `sudo setpriv`, proven against a real
// account by `spawn-as-member.live.test.ts` reading file ownership rather than trusting the process
// - the credential is theirs — `--reset-env` plus an allowlist, so the owner's proxy variables cannot cross
// - the transcripts are theirs — `ChatIdentity` carries a home resolved from `resolveHomeDir`, and this file
// no longer knows how to invent one
// - the sessions are theirs — every session records its owner, and all six sidecar commands refuse a
// mismatch rather than acting on whoever matched
//
// Each of those is a separate commit with its own reasoning, and each was found wanting at least once by a
// reviewer who had not written it. If you are reverting this, revert to a refusal — not to a narrower one.
// The working directory a request operates on: an explicit ?cwd= (a chosen pwd), else the default
// caller's own home. Claude groups sessions by cwd, so this selects which project group we read.
// OpenCode runs on one fixed serve, but each session records the directory its turn ran in, so cwd
// selects there too.
const cwdOf = (ctx: Context, home: string): string => ctx.req.query('cwd')?.trim() || home;
// GET /chat/pwds — the default /chat dir plus every directory that already has Claude sessions.
chatRouter.get('/pwds', async (ctx) => {
const who = await chatIdentity(ctx.get('user'));
return ctx.json({ pwds: listClaudePwds(who), default: who.home });
});
// GET /chat/sessions[?cwd=] — conversations for a working directory, merged across both harnesses
// (Claude transcripts + OpenCode's session store), newest first.
chatRouter.get('/sessions', async (ctx) => {
const who = await chatIdentity(ctx.get('user'));
const cwd = cwdOf(ctx, who.home);
const claude = listClaudeSessions(who, cwd).map((s) => ({ ...s, harness: 'claude' as const }));
// OpenCode's store is shared and unscoped, so for anyone but the owner this list is other people's
// conversations. Empty rather than filtered: there is no per-user field to filter ON.
const opencode = who.isOwner ? await listOpenCodeSessions(cwd) : [];
const sessions = [...claude, ...opencode].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
return ctx.json({ sessions });
});
// GET /chat/sessions/:id[?cwd=][&limit=&before=] — one conversation's transcript. Routes by harness
// (ses_ = OpenCode). Transcripts get extremely long, so the client opens at the tail and pages upward:
// `limit` caps how many messages come back and `before` (absolute index into the full transcript,
// exclusive) selects the window's upper bound. Absent params return the whole transcript (legacy). The
// response carries `total` (full length) and `offset` (absolute index of messages[0]) so the client knows
// where the window sits and whether older messages remain above it.
chatRouter.get('/sessions/:id', async (ctx) => {
const who = await chatIdentity(ctx.get('user'));
const id = ctx.req.param('id');
const cwd = cwdOf(ctx, who.home);
// Fall back to a by-id scan when the (default) cwd doesn't hold it — a fresh /chat/<id> deep-link/refresh
// doesn't know the session's cwd. The returned detail carries the real cwd for the client to scope the UI.
// 404 rather than 403 on the OpenCode branch: a non-owner has no way to tell a session they may not read
// from one that does not exist, which is the honest answer when the store has no notion of whose it is.
if (isOpenCodeSessionId(id) && !who.isOwner) return ctx.text('Not found', 404);
const detail = isOpenCodeSessionId(id)
? await loadOpenCodeSession(id)
: (loadClaudeSession(who, cwd, id) ?? loadClaudeSessionById(who, id));
if (!detail) return ctx.text('Not found', 404);
const total = detail.messages.length;
const limitRaw = ctx.req.query('limit');
const beforeRaw = ctx.req.query('before');
const limit = limitRaw != null ? Math.max(0, parseInt(limitRaw, 10) || 0) : null;
const end = beforeRaw != null ? Math.min(total, Math.max(0, parseInt(beforeRaw, 10) || 0)) : total;
const start = limit != null ? Math.max(0, end - limit) : 0;
const windowed = limit != null || beforeRaw != null;
const messages = windowed ? detail.messages.slice(start, end) : detail.messages;
// The title the list shows this session under, and how many `/clear` parts it spans. Resolved against
// `detail.cwd` — the transcript's own directory — not the requested `cwd`, which on a deep link is
// still the default group and holds none of this session's neighbours. OpenCode has no chains of its
// own, so it gets neither rather than a fabricated answer.
const context = isOpenCodeSessionId(id) ? null : claudeSessionContext(who, detail.cwd, id);
return ctx.json({
...detail,
messages,
total,
offset: windowed ? start : 0,
title: context?.title ?? null,
partCount: context?.partCount ?? 1,
});
});
// GET /chat/live — the sessions the agent has a process behind RIGHT NOW, as opposed to the transcripts
// on disk that `/chat/sessions` lists. Asked over the wire because only the agent can answer: officer's
// own session records are in memory and die with `pm2 restart officer`, while the agent is a PM2 peer
// and keeps running. Without this a surviving session is invisible until a browser reconnects to it by
// id, which is a thing you can only do if you already knew the id.
//
// `pendingTasks` is background work started but not yet notified — with `isGenerating` it is what the
// agent's own idle GC consults, so a caller can tell "busy" from "merely open" the same way it does.
// Titles are resolved here rather than in the client, which can only name the sessions in the group it
// happens to be browsing — which is how the list ended up showing raw ids for anything running elsewhere.
chatRouter.get('/live', async (ctx) => {
const user = ctx.get('user');
const who = await chatIdentity(user);
const email = who.email;
// Both harnesses, asked in parallel. Either failing contributes nothing rather than failing the panel:
// both registry calls swallow their errors and return [].
// The Claude call is already scoped by userId; the OpenCode one has no such argument, so it is asked only
// for the owner. A member's Live panel therefore shows their own turns and nothing else.
const [live, liveOpenCode] = await Promise.all([
sidecar.listLiveClaudeSessions(user.id),
who.isOwner ? sidecar.listLiveOpenCodeSessions() : Promise.resolve([]),
]);
const sessions = live.map((session) => {
// Resolve by Claude's id, never by the session key — the key is officer's handle and the transcript
// is named after Claude's. Null until the first turn reports one, which is a conversation that has
// genuinely not been written yet.
const transcriptId = session.claudeSessionId;
const resolved = transcriptId && !isOpenCodeSessionId(transcriptId) ? liveSessionTitle(who, transcriptId) : null;
return { ...session, harness: 'claude' as const, title: resolved?.title ?? null, cwd: resolved?.cwd ?? null };
});
// OpenCode rows carry less, and the shape says so rather than faking parity. `isGenerating` is always
// true because a subprocess exists only while it generates, and `pendingTasks` is 0 because
// `opencode run` has no background-task concept — a number there would imply one.
//
// Naming them needs a second hop. The sidecar reports only its own `sessionKey`, because that is all it
// has; OpenCode's store is keyed on `ses_…`, which the runner reports separately over `opencode:session`
// and officer records in `opencode/state.ts`. So the correlation lives HERE, and it is the reason these
// rows used to be permanently unnamed — the two halves existed and nothing joined them.
const withIds = liveOpenCode.map((session) => ({
sessionKey: session.sessionKey,
openCodeId: getOpenCodeSession(session.sessionKey) ?? null,
}));
// One list call names every row, rather than one transcript load each — `loadOpenCodeSession` rebuilds
// a whole conversation to read its title. Skipped entirely when nothing is running or nothing has
// reported an id yet, so an idle Live panel never touches the serve. Failure degrades to unnamed:
// `listOpenCodeSessions` already swallows and returns [], which matches how the rest of this route
// fails.
const named = withIds.some((r) => r.openCodeId) ? await listOpenCodeSessions() : [];
const byId = new Map(named.map((s) => [s.id, s]));
const openCodeSessions = withIds.map(({ sessionKey, openCodeId }) => {
const meta = openCodeId ? byId.get(openCodeId) : undefined;
return {
sessionKey,
claudeSessionId: openCodeId,
isGenerating: true,
pendingTasks: 0,
harness: 'opencode' as const,
// OpenCode's own title wins as soon as it exists — it is derived from the conversation and is
// better than anything we would compose. Until then (and it titles asynchronously, so "until
// then" covers the whole time a turn is RUNNING, which is exactly what this panel shows) fall
// back to the prompt that started the session. Same shape as the Claude side, which has never
// shown a live row without a name.
title: meta?.title && meta.title !== '(untitled)' ? meta.title : (getOpenCodePrompt(sessionKey) ?? null),
cwd: meta?.cwd || null,
};
});
return ctx.json({ sessions: [...sessions, ...openCodeSessions] });
});
// DELETE /chat/sessions/:id[?cwd=] — remove a conversation from the owning harness's store. For a
// Claude `/clear` chain that is every part of it: the list shows the chain as one conversation, so
// deleting it deletes one conversation.
chatRouter.delete('/sessions/:id', async (ctx) => {
const who = await chatIdentity(ctx.get('user'));
const id = ctx.req.param('id');
const cwd = cwdOf(ctx, who.home);
if (isOpenCodeSessionId(id) && !who.isOwner) return ctx.text('Not found', 404);
const ok = isOpenCodeSessionId(id) ? await deleteOpenCodeSession(id) : deleteClaudeSession(who, cwd, id);
if (!ok) return ctx.text('Not found', 404);
return ctx.json({ ok: true });
});
// PATCH /chat/sessions/:id/title[?cwd=] — rename in the owning harness's store.
chatRouter.patch('/sessions/:id/title', async (ctx) => {
const who = await chatIdentity(ctx.get('user'));
const id = ctx.req.param('id');
const cwd = cwdOf(ctx, who.home);
const { title } = await ctx.req.json<{ title?: string }>();
if (!title?.trim()) return ctx.text('title is required', 400);
if (isOpenCodeSessionId(id) && !who.isOwner) return ctx.text('Not found', 404);
const ok = isOpenCodeSessionId(id)
? await renameOpenCodeSession(id, title.trim())
: renameClaudeSession(who, cwd, id, title.trim());
if (!ok) return ctx.text('Not found', 404);
return ctx.json({ ok: true });
});
// GET /chat/tasks/:id — what a background task is doing right now: a subagent's own trace, or the tail of
// a backgrounded shell's log. Polled by the tray above the chat input while the task is running.
//
// A task that has not written anything yet answers 200 with `{ kind: 'pending' }`, not 404. The tray asks
// the moment `task:started` arrives, which is routinely before the file exists, and a 404 there would be
// an error state for the most ordinary thing that can happen.
chatRouter.get('/tasks/:id', async (ctx) => {
const who = await chatIdentity(ctx.get('user'));
const detail = loadBackgroundTask(who, ctx.req.param('id'));
return ctx.json(detail ?? { kind: 'pending' });
});
// DELETE /chat/tasks/:id — stop a running background job.
//
// Exists so a job can be stopped by pressing a button rather than by asking the agent in words: a turn to
// say "please stop that" is a turn, and it only works while the agent is responsive.
chatRouter.delete('/tasks/:id', async (ctx) => {
const who = await chatIdentity(ctx.get('user'));
const result = await stopBackgroundTask(who, ctx.req.param('id'));
return ctx.json(result, result.stopped ? 200 : 400);
});
// POST /chat/tasks/:id/restart — run this job's command again, without involving the agent.
//
// The platform supervises what it respawns, because the harness stopped watching when its own child died.
// It emits the same two events a harness-owned task emits, so the tray, the log view and reconnects need to
// know nothing about the difference. `sessionId` comes from the client because that is who knows which
// conversation the chip is being looked at in.
chatRouter.post('/tasks/:id/restart', async (ctx) => {
const who = await chatIdentity(ctx.get('user'));
const taskId = ctx.req.param('id');
const body = await ctx.req.json<{ sessionId?: string }>().catch(() => ({}) as { sessionId?: string });
const result = await restartBackgroundTask(who, taskId, (code: number) => {
if (!body.sessionId) return;
emitToSession(body.sessionId, {
type: 'task:notification',
taskId,
status: code === 0 ? 'completed' : 'failed',
summary: code === 0 ? 'Restarted job finished' : `Restarted job exited with code ${code}`,
});
});
// Announced only on success, and only after the process exists — a `task:started` for something that
// failed to spawn would leave a chip running forever with nothing behind it.
if (result.ok && body.sessionId) {
emitToSession(body.sessionId, {
type: 'task:started',
taskId,
description: result.command?.split('\n')[0]?.slice(0, 80) ?? 'restarted job',
taskType: 'local_bash',
});
}
return ctx.json(result, result.ok ? 200 : 400);
});
// POST /chat/tasks/:id/pause — freeze or thaw a running job, keeping everything it holds.
chatRouter.post('/tasks/:id/pause', async (ctx) => {
const who = await chatIdentity(ctx.get('user'));
const body = await ctx.req.json<{ paused?: boolean }>().catch(() => ({}) as { paused?: boolean });
const result = await pauseBackgroundTask(who, ctx.req.param('id'), body.paused !== false);
return ctx.json(result, result.ok ? 200 : 400);
});
// GET /chat/models — Claude tiers only (the runner is the `claude` CLI).
chatRouter.get('/models', async (ctx: Context) => {
const who = await chatIdentity(ctx.get('user'));
try {
const all = await listChatModels();
// Hiding these is a courtesy — the socket refuses them regardless — but offering a model that cannot run
// is how a member ends up reporting "chat is broken" for a choice the UI made available.
const models = who.isOwner ? all : all.filter((m) => m.provider === 'claude-code');
const providerNames: Record<string, string> = { 'claude-code': 'Claude Code', opencode: 'OpenCode Zen' };
return ctx.json({ models, providerNames, hostHome: process.env.HOME ?? '' });
} catch (err) {
logger.error('Failed to list models', { error: String(err) });
return ctx.json({ models: [], providerNames: {} });
}
});
// POST /chat/stt — proxy an audio clip to the configured Whisper server.
chatRouter.post('/stt', async (ctx: Context) => {
const sttConfig = await readSttConfig();
if (!sttConfig?.url) {
return ctx.json({ error: 'Whisper not configured — set it up in Settings → Speech to Text' }, 400);
}
const user = ctx.get('user');
const body = await ctx.req.parseBody();
const file = body['file'];
if (!file || !(file instanceof File)) {
return ctx.json({ error: 'file is required' }, 400);
}
const settings = (await getUserSettings(user.id)) as { languages?: { spoken?: string[] } };
const spokenLanguages = settings.languages?.spoken ?? [];
try {
const result = await transcribeAudio({ file, whisperUrl: sttConfig.url, spokenLanguages });
return ctx.json(result);
} catch (err) {
logger.error('STT proxy failed', { error: String(err) });
return ctx.json({ error: err instanceof Error ? err.message : 'Failed to reach Whisper server' }, 502);
}
});
// The agent address book — naming a chat panel so other agents on the same dashboard can reach it.
// Lives on this router because it is the same authority `chat` already grants: creating and naming
// Claude sessions. See servers/api/chat/agent-panels-routes.ts and docs/agent-coordination.md.
registerAgentPanelRoutes(chatRouter);