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 { 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()); 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(), ); // 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()); 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/ 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 = { '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 }); });