Seven variables out of .env. DATA_PATH, OFFICER_ITEMS_DIR and HOME_DIR are gone from the code entirely; PUBLIC_URL, PUBLIC_BUILD_ENV, JWT_SECRET and VAULT_STORE_KEY are no longer written by the setup script. data-path.ts now derives OFFICER_ROOT as dirname(process.cwd()), with data/, capabilities/ and dockers/ as fixed names under it. The direction used to run the other way — DATA_PATH from env, then OFFICER_ROOT = dirname(DATA_PATH) in app-store/paths.ts — which meant three environment variables that had to agree with each other and with the tree on disk. Eight files re-read process.env.DATA_PATH independently, each with its own `?? cwd()/data` fallback. They import the one value now, which is what made removing it safe: otherwise each would have derived its own and drifted. Three things this turned up. The cwd pin in ecosystem.profile.cjs was broken. It set `cwd: __dirname` under a comment asserting "__dirname is the repo root — this file sits beside ecosystem.config.cjs", which stopped being true when these files moved into ecosystem-files/. It walks up to the platform's package.json now, which holds wherever the file lives. That was a live bug before this change and a load-bearing one after it, since cwd now decides where the install is. assertInstallLayout joins the other two boot assertions. A wrong cwd does not error — it computes a plausible root somewhere else and writes managed homes and agent runs into it, so the install looks empty and the data looks lost with nothing naming the cause. It throws before serve(), first of the three, because a wrong answer there makes the other two check the wrong files. getOwnerHomeDir captures homedir() once at module load rather than per call. Measured on bun 1.3.10: both os.homedir() and os.userInfo().homedir return $HOME when set rather than reading passwd, and user-instance.ts assigns process.env.HOME on its way to spawning an agent. A lazy read would have returned the owner's home on the first call and a member's afterwards. data-path.ts imports only node builtins, so it is evaluated before any of that runs. JWT_SECRET and VAULT_STORE_KEY leaving .env means an install made by this script does not boot — jwt.ts throws at module load without one. That is the agreed sequencing: they move to the SQLite store (docs/secret-store.md), and writing them here meanwhile would create a second origin for a secret the store then has to be reconciled with. Said plainly in .env.example and in lib/env.sh rather than left to be discovered. Not typechecked: node_modules is empty here and installs are frozen. Every edited file parses under `bun build --no-bundle`; the profile loads and pins the right cwd; assertInstallLayout was exercised from both the repo and /tmp; the setup section was run and writes five variables. Prettier was NOT run — 3.9.6 via bunx is not the pinned resolution and reformatted unrelated unions and line wraps in six files, so those were reverted and the edits re-applied by hand. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
289 lines
15 KiB
TypeScript
289 lines
15 KiB
TypeScript
import type { Context } from 'hono';
|
|
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,
|
|
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.
|
|
*
|
|
* 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 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 }));
|
|
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.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.
|
|
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.home);
|
|
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);
|
|
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);
|