The history layer, and the last change that could be made without a live member.
claude-sessions.ts had `claudeHome = process.env.HOME_DIR ?? join(DATA_PATH,
email, 'home')`, which discards its argument whenever HOME_DIR is set — always,
on a real install. Every transcript read therefore resolved to the OWNER'S
~/.claude no matter who asked, and the comment above it asserted "single-user
platform" as though that were a property rather than an assumption. A member
reaching these functions would have been handed the owner's conversation list.
Now every read takes a ChatIdentity {email, home} with the home resolved from
resolveHomeDir(userId), and this file has no way to invent one. Both fields
travel together because they are genuinely different: general_chat_sessions
lives under DATA_PATH/<email>, not under a home. Collapsing them would be the
same class of mistake as undefined meaning "the owner".
websocket.ts's resolveCwd takes a home, so `~` expands against the caller's own.
Identity is resolved BEFORE the cwd — expanding `~` before knowing whose home it
is would be exactly the bug being removed — which also let a duplicate
resolveTurnIdentity call from 6aeb304 be deleted.
chat.ts resolves per request and throws FORBIDDEN rather than falling back, same
posture as resolveTurnIdentity. agent-runner passes the owner's home explicitly
rather than inheriting it, since that path really is owner-only.
Made at 01:00 after saying it should not be. Three things I am least sure of are
listed in COMMS 29 rather than left for the reviewer to find: chat routes now
have a failure mode they did not have, resolveBaseCwd's exported parameter
changed meaning rather than shape, and the bare-email rewrite in chat.ts was
mechanical with hand repair.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
291 lines
15 KiB
TypeScript
291 lines
15 KiB
TypeScript
import type { Context } from 'hono';
|
|
import { createRouter } from '../../create-router';
|
|
import { isSuperAdmin } from '@@/super-admin';
|
|
import * as errors from '@@/custom-errors';
|
|
import * as sidecar from '@@/sidecar-registry';
|
|
import { getUserSettings } from 'officerdb';
|
|
import {
|
|
getGeneralChatSessionsCwd,
|
|
listClaudePwds,
|
|
listClaudeSessions,
|
|
loadClaudeSession,
|
|
loadClaudeSessionById,
|
|
deleteClaudeSession,
|
|
renameClaudeSession,
|
|
loadBackgroundTask,
|
|
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 whenever
|
|
* HOME_DIR is set, 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.
|
|
*
|
|
* Unreachable by a member today — the router refuses non-owners above — so this is the path being made correct
|
|
* before it is opened, not a live fix.
|
|
*/
|
|
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 };
|
|
}
|
|
import { transcribeAudio } from '../stt/transcribe';
|
|
import { registerAgentPanelRoutes } from './agent-panels-routes';
|
|
|
|
export const chatRouter = createRouter();
|
|
|
|
// ── Chat is grantable, and its machinery is not ready for a member. This is that gap, held open on purpose ──
|
|
//
|
|
// The `chat` capability moved from `execution` to `confined` so the owner can grant it and the route resolves.
|
|
// The agent underneath has NOT moved: `claude-manager.ts` drives turns through the Agent SDK, which spawns
|
|
// `claude` itself with no way to hand it a uid, and every transcript path here resolves through the owner's
|
|
// home. So a member reaching this router would read the owner's session list and run an agent as the owner —
|
|
// which is the whole thing the confinement work exists to prevent.
|
|
//
|
|
// Refused wholesale rather than per-route, and reads rather than just writes: `listClaudePwds` returns the
|
|
// directory names of the owner's projects, which is not a member's business either.
|
|
//
|
|
// What lifts this is per-user agents: the turn becomes its own process under `runAs`, with the member's own
|
|
// HOME so `~/.claude` and their transcripts are theirs. docs/per-user-linux-accounts.md § stage 5. Delete this
|
|
// middleware then — it is the only thing standing between a granted member and the owner's agent.
|
|
chatRouter.use(async (ctx, next) => {
|
|
if (!(await isSuperAdmin(ctx.get('user')))) {
|
|
throw errors.FORBIDDEN('Chat is not available to members yet — the agent still runs as the server owner.');
|
|
}
|
|
return next();
|
|
});
|
|
|
|
// The working directory a request operates on: an explicit ?cwd= (a chosen pwd), else the default
|
|
// general_chat_sessions dir. 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, email: string): string => ctx.req.query('cwd')?.trim() || getGeneralChatSessionsCwd(email);
|
|
|
|
// 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: getGeneralChatSessionsCwd(who.email) });
|
|
});
|
|
|
|
// 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.email);
|
|
const claude = listClaudeSessions(who, cwd).map((s) => ({ ...s, harness: 'claude' as const }));
|
|
const opencode = 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.email);
|
|
// 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.
|
|
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 [].
|
|
const [live, liveOpenCode] = await Promise.all([
|
|
sidecar.listLiveClaudeSessions(user.id),
|
|
sidecar.listLiveOpenCodeSessions(),
|
|
]);
|
|
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.email);
|
|
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.email);
|
|
const { title } = await ctx.req.json<{ title?: string }>();
|
|
if (!title?.trim()) return ctx.text('title is required', 400);
|
|
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' });
|
|
});
|
|
|
|
// GET /chat/models — Claude tiers only (the runner is the `claude` CLI).
|
|
chatRouter.get('/models', async (ctx: Context) => {
|
|
try {
|
|
const models = await listChatModels();
|
|
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);
|