Fixing a regression I introduced last night. Commander rows in the Live list were changed to open in /chat rather than navigating to Command HQ — right, and it cost the composer its commander controls, because `isCommander` was computed from the ROUTE: `manager.context === 'command-hq'`. So the same commander, rendered on a different screen, silently lost "exit and hand back, then re-arm" — the only fire behaviour a watcher wants, and the one the dialog makes default inside a commander. GET /chat/sessions/:id now reports isCommander, derived from the session's own cwd by the same isCommandersDir already used by the sessions list and /chat/live. The answer travels with the conversation. The composer reads one value, manager.isCommander, true when EITHER source says so — and both are needed. The screen knows when Command HQ starts a commander, before a session or a transcript exists for any route to report on. The session knows once it does, wherever it is opened. Screen-only was this bug; session-only would have broken the brand-new composer, which is where the whole thing starts. Verified on a real commander rather than by reading: 172cdd8c resolves cwd=…/commanders and isCommander=true. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
629 lines
33 KiB
TypeScript
629 lines
33 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,
|
|
getChatSessionModel,
|
|
getChatSessionModels,
|
|
listCommandHqSessionIds,
|
|
getChatNotifyPreference,
|
|
getChatNotifyPreferences,
|
|
setChatNotifyPreference,
|
|
} from 'officerdb';
|
|
import type { NotifyPreference } from 'officerdb/types';
|
|
import { setNudge, getNudge, cancelNudge } from 'officerdb';
|
|
import {
|
|
listClaudePwds,
|
|
listClaudeSessions,
|
|
listAllClaudeSessions,
|
|
loadClaudeSession,
|
|
loadClaudeSessionById,
|
|
deleteClaudeSession,
|
|
renameClaudeSession,
|
|
loadBackgroundTask,
|
|
stopBackgroundTask,
|
|
claudeSessionContext,
|
|
liveSessionTitle,
|
|
isCommandersDir,
|
|
} 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 { officerModelForApiModel } from './model-aliases';
|
|
import { readAccountEmail } from './claude-usage';
|
|
import { listStoredUsage, mergeUsage, snapshotClaudeUsage } from './usage-store';
|
|
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 user = ctx.get('user');
|
|
const who = await chatIdentity(user);
|
|
const cwd = cwdOf(ctx, who.home);
|
|
|
|
// `?all=1` — every directory at once, for the list's Latest tab. A separate parameter rather than a
|
|
// separate route because the response shape is identical and every consumer downstream (rename, delete,
|
|
// the row renderer) is indifferent to which directories the rows came from.
|
|
const all = ctx.req.query('all') === '1';
|
|
|
|
const claude = (all ? listAllClaudeSessions(who) : 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.
|
|
//
|
|
// `all` does not widen this: OpenCode's listing is already per-cwd with no all-directories form, and
|
|
// inventing one by looping would need a directory list it does not publish. So Latest is Claude-only
|
|
// for now, which is honest — every OpenCode session is still reachable from its own directory in Tree.
|
|
const opencode = who.isOwner && !all ? await listOpenCodeSessions(cwd) : [];
|
|
const merged = [...claude, ...opencode].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
|
|
|
/*
|
|
Command HQ's filed commanders are excluded here, never rendered as a second conversation somewhere
|
|
else. This is the opposite side of the ONE predicate `command_hq_sessions` exists to be — Command HQ's
|
|
own grid includes exactly what this excludes. Fetched fresh per request rather than cached: the set is
|
|
small (one row per commander) and staleness here would mean a filed commander briefly reappearing in
|
|
`/chat`, which is a worse failure than the extra query.
|
|
|
|
Never fatal — a database hiccup on a feature /chat does not otherwise depend on must not take the whole
|
|
conversation list down with it, the same posture `getChatSessionModels` below already takes.
|
|
*/
|
|
const filed = await listCommandHqSessionIds(user.id).catch(() => new Set<string>());
|
|
const sessions = merged.filter((s) => !filed.has(s.id));
|
|
|
|
/*
|
|
The model each session is on, batched.
|
|
|
|
One query for the whole page rather than one per row — that is what `getChatSessionModels` exists for.
|
|
A session with no row yet resolves to null and the row shows no model, which is the same "unknown rather
|
|
than guessed" rule the picker follows. Never fatal: a database that will not answer this must not take
|
|
the conversation list down with it.
|
|
*/
|
|
const models = await getChatSessionModels(sessions.map((s) => s.id)).catch(
|
|
() => new Map<string, { model: string; planMode: boolean }>(),
|
|
);
|
|
|
|
// Same batched-not-per-row shape as `models` above, for the same reason — the list renders a bell
|
|
// badge per row, and per-row queries do not scale to a page of a hundred sessions.
|
|
const notifyPrefs = await getChatNotifyPreferences(
|
|
user.id,
|
|
sessions.map((s) => s.id),
|
|
).catch(() => new Map<string, NotifyPreference>());
|
|
|
|
return ctx.json({
|
|
sessions: sessions.map((s) => {
|
|
const stored = models.get(s.id);
|
|
// Spelled out field by field rather than spread: this row goes onto the wire untyped (`ctx.json`),
|
|
// so widening the stored shape from a string to a record would have silently shipped an OBJECT as
|
|
// `model` and broken every consumer that calls `.split('/')` on it — with nothing to catch it.
|
|
return {
|
|
...s,
|
|
model: stored?.model ?? null,
|
|
planMode: stored?.planMode ?? false,
|
|
notifyPreference: notifyPrefs.get(s.id) ?? 'none',
|
|
};
|
|
}),
|
|
});
|
|
});
|
|
|
|
// 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 user = ctx.get('user');
|
|
const who = await chatIdentity(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);
|
|
|
|
/*
|
|
The model the PICKER should show, which is not the model `detail` reports.
|
|
|
|
`detail.model` is scraped out of the transcript, so it is an API id (`claude-opus-5`) — a different id
|
|
space from what the picker offers (`claude-code/opus[1m]`), and lossy: `opus` and `opus[1m]` both write
|
|
`claude-opus-5`, and `opusplan` writes whichever model answered. Handing that to the client resolved to
|
|
nothing in the catalogue, the picker fell back to the first entry, and a resumed conversation came back
|
|
showing the wrong model.
|
|
|
|
So the stored row wins where there is one, and the family guess is the fallback for sessions that have
|
|
not taken a turn since `chat_session_models` landed. Null when neither can answer — the client shows no
|
|
model rather than guessing, which is the whole defect being fixed.
|
|
*/
|
|
const stored = await getChatSessionModel(id).catch(() => null);
|
|
const officerModel = stored?.model ?? officerModelForApiModel(detail.model) ?? null;
|
|
const notifyPreference = (await getChatNotifyPreference(user.id, id).catch(() => null)) ?? 'none';
|
|
|
|
return ctx.json({
|
|
...detail,
|
|
// `detail.model` stays on the payload untouched — it is the honest record of what the API served, and
|
|
// the detail view may want it. `officerModel` is the separate question of what to select in the picker.
|
|
officerModel,
|
|
// Whether this conversation is planning, so the composer's toggle comes back in the state the user left
|
|
// it. Only a stored row can answer — plan mode leaves no trace in the transcript at all.
|
|
planMode: stored?.planMode ?? false,
|
|
notifyPreference,
|
|
messages,
|
|
total,
|
|
offset: windowed ? start : 0,
|
|
title: context?.title ?? null,
|
|
partCount: context?.partCount ?? 1,
|
|
/*
|
|
Whether this conversation is a commander, derived from ITS OWN cwd rather than from the screen asking.
|
|
|
|
The composer offers "exit and hand back, then re-arm" — the only fire behaviour a watcher wants — and
|
|
decided whether to by looking at the route it was rendered on. So the same commander, opened from
|
|
/chat instead of Command HQ, silently lost the control that is the whole point of it. A commander is a
|
|
commander wherever it is rendered, and `detail.cwd` is the transcript's own directory, so this answer
|
|
travels with the conversation.
|
|
*/
|
|
isCommander: isCommandersDir(who.email, detail.cwd),
|
|
});
|
|
});
|
|
|
|
// 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;
|
|
const cwd = resolved?.cwd ?? null;
|
|
return {
|
|
...session,
|
|
harness: 'claude' as const,
|
|
title: resolved?.title ?? null,
|
|
cwd,
|
|
// Derived from the cwd rather than looked up in `command_hq_sessions`, for the same reason the
|
|
// history listing does it: every commander shares one directory, so the directory IS the answer and
|
|
// it needs no query. It also stays right for a commander mid-first-turn, before any row exists.
|
|
//
|
|
// This field is why the route changed at all. The iPhone app swipes between LIVE chats and has no
|
|
// room for tabs, so it can only exclude commanders if the payload says which ones they are — and a
|
|
// deploy commander turning up in that swipe order is exactly what it must not do.
|
|
isCommander: cwd ? isCommandersDir(who.email, cwd) : false,
|
|
};
|
|
});
|
|
|
|
// 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,
|
|
// Always false rather than absent: a commander is started through Command HQ, which runs claude.
|
|
// Stating it keeps the two row shapes answerable by one client filter.
|
|
isCommander: false,
|
|
};
|
|
});
|
|
|
|
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 });
|
|
});
|
|
|
|
const NOTIFY_PREFERENCES: NotifyPreference[] = ['every_turn', 'input_needed', 'none'];
|
|
|
|
// PATCH /chat/sessions/:id/notify-pref — set this session's bell preference, FOR THIS CALLER.
|
|
//
|
|
// The row is keyed on (user_id, session_id), so writing one for a session you cannot reach affects only
|
|
// your own row and nothing ever fires it. That was previously claimed of a table keyed on session_id
|
|
// alone, where it was false — delivery read the preference globally, so setting one on someone else's
|
|
// conversation switched off THEIR notifications. See the schema for the full note.
|
|
chatRouter.patch('/sessions/:id/notify-pref', async (ctx) => {
|
|
const user = ctx.get('user');
|
|
await chatIdentity(user);
|
|
const id = ctx.req.param('id');
|
|
const { preference } = await ctx.req.json<{ preference?: string }>();
|
|
if (!preference || !NOTIFY_PREFERENCES.includes(preference as NotifyPreference)) {
|
|
return ctx.text('preference must be one of: ' + NOTIFY_PREFERENCES.join(', '), 400);
|
|
}
|
|
await setChatNotifyPreference(user.id, id, preference as NotifyPreference);
|
|
return ctx.json({ preference });
|
|
});
|
|
|
|
// 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'));
|
|
// `?force=1` escalates to SIGKILL. A query parameter rather than a second route: it is the same verb on
|
|
// the same thing, differing only in how hard it asks.
|
|
const force = ctx.req.query('force') === '1';
|
|
const result = await stopBackgroundTask(who, ctx.req.param('id'), force);
|
|
// "no such task" and "already stopped" are 200, not 400.
|
|
//
|
|
// DELETE is idempotent: asking for a job to stop when it is already gone has achieved what was asked.
|
|
// Reporting it as an error made the tray shout `{"stopped":false,"reason":"no such task"}` at somebody
|
|
// pressing Stop on a chip that could not be stopped, killed or dismissed — and because `useClient`
|
|
// raises a GLOBAL toast before the caller ever sees the failure, no amount of handling at the call
|
|
// site could quiet it.
|
|
//
|
|
// A genuine refusal — a malformed id, a subagent that has no fd to trace — is still 400.
|
|
const gone = result.reason === 'no such task' || result.reason === 'already stopped';
|
|
return ctx.json({ ...result, gone }, result.stopped || gone ? 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;
|
|
// Fire-and-forget because the exit arrives long after this request answered; `emitToSession` swallows
|
|
// its own failures, so there is nothing here to await for.
|
|
void 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.
|
|
//
|
|
// Awaited, so the event is in the durable log before the client is told the restart worked. The tray is
|
|
// rebuilt from that log on every refresh, and a client that reloaded in the gap would have folded the
|
|
// ORIGINAL task's `task:notification` as the last word and shown nothing — which is precisely the bug
|
|
// this replaced: a live dev server with no chip and no way to reach it, since every task route needs an
|
|
// id the browser had just thrown away.
|
|
if (result.ok && body.sessionId) {
|
|
await 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).
|
|
/*
|
|
GET /chat/usage — what Claude Code's own usage display shows.
|
|
|
|
OWNER ONLY, and not because of the registry: `chat` is a `confined` permission, so a member holding it
|
|
reaches every route in this router. These are the OWNER'S subscription windows and the OWNER'S transcripts,
|
|
spent by every `claude` on the machine — a member seeing them would be reading someone else's account, and
|
|
a member seeing them go red would be reading a bill they cannot act on.
|
|
|
|
A 403 rather than an empty reading: there is no per-member version of this to fall back to. Members run
|
|
`claude` as their own Linux account with their own credential, so the honest answer to "what is my usage"
|
|
for them is a different query against a different login, and inventing it here would answer the wrong one.
|
|
*/
|
|
chatRouter.get('/usage', async (ctx) => {
|
|
const who = await chatIdentity(ctx.get('user'));
|
|
const user = ctx.get('user');
|
|
if (!who.isOwner) throw errors.FORBIDDEN('Usage belongs to the account that owns this machine');
|
|
|
|
const currentEmail = await readAccountEmail();
|
|
const stored = await listStoredUsage(user.id, currentEmail);
|
|
|
|
/*
|
|
Two modes, and the split is the whole point of the cache.
|
|
|
|
Without `?refresh=1` this answers from Postgres alone — no transcript scan, no network — so the panel
|
|
paints instantly with every account, including the ones that CANNOT be read live because the CLI is not
|
|
signed in as them. The client then asks again with the flag and swaps in the fresh tab.
|
|
|
|
The exception is a first open, where answering instantly with nothing would be a worse kind of fast: if
|
|
the signed-in account has no stored row, measure it now rather than show an empty panel.
|
|
*/
|
|
const refresh = ctx.req.query('refresh') === '1';
|
|
const missing = !!currentEmail && !stored.some((a) => a.current);
|
|
if (!refresh && !missing) return ctx.json({ accounts: stored });
|
|
|
|
return ctx.json({ accounts: mergeUsage(stored, await snapshotClaudeUsage(user.id)) });
|
|
});
|
|
|
|
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);
|
|
|
|
// ── Nudger ──
|
|
//
|
|
// A schedule that keeps one chat moving while nobody is watching it. The tick lives in ./nudger.ts; these
|
|
// are only the three verbs the composer needs.
|
|
//
|
|
// Scoped to the caller like every other per-session route here. A nudge is a prompt sent AS someone, so a
|
|
// schedule readable or writable across accounts would be a way to speak in another person's chat.
|
|
|
|
const MIN_EVERY_SECONDS = 60;
|
|
const MAX_EVERY_SECONDS = 86_400;
|
|
|
|
// GET /chat/sessions/:id/nudge — the current schedule, or null.
|
|
chatRouter.get('/sessions/:id/nudge', async (ctx) => {
|
|
const user = ctx.get('user');
|
|
await chatIdentity(user);
|
|
return ctx.json({ nudge: await getNudge(user.id, ctx.req.param('id')) });
|
|
});
|
|
|
|
// PUT /chat/sessions/:id/nudge — arm or re-arm. Re-arming resets the count: it is a new night.
|
|
chatRouter.put('/sessions/:id/nudge', async (ctx) => {
|
|
const user = ctx.get('user');
|
|
await chatIdentity(user);
|
|
|
|
const body = await ctx.req.json<{ message?: string; everySeconds?: number; maxCount?: number }>();
|
|
const message = typeof body.message === 'string' ? body.message.trim() : '';
|
|
if (!message) return ctx.text('message is required', 400);
|
|
|
|
const everySeconds = Number(body.everySeconds ?? 3600);
|
|
const maxCount = Number(body.maxCount ?? 12);
|
|
// The floor is the tick interval — anything under it is a lie about cadence rather than a faster nudge.
|
|
if (!Number.isFinite(everySeconds) || everySeconds < MIN_EVERY_SECONDS || everySeconds > MAX_EVERY_SECONDS) {
|
|
return ctx.text(`everySeconds must be between ${MIN_EVERY_SECONDS} and ${MAX_EVERY_SECONDS}`, 400);
|
|
}
|
|
if (!Number.isFinite(maxCount) || maxCount < 1 || maxCount > 100) {
|
|
return ctx.text('maxCount must be between 1 and 100', 400);
|
|
}
|
|
|
|
const nudge = await setNudge({ userId: user.id, sessionId: ctx.req.param('id'), message, everySeconds, maxCount });
|
|
return ctx.json({ nudge });
|
|
});
|
|
|
|
// DELETE /chat/sessions/:id/nudge — disarm. Idempotent: cancelling an absent schedule is success.
|
|
chatRouter.delete('/sessions/:id/nudge', async (ctx) => {
|
|
const user = ctx.get('user');
|
|
await chatIdentity(user);
|
|
await cancelNudge(user.id, ctx.req.param('id'));
|
|
return ctx.json({ ok: true });
|
|
});
|