start a commander from a plugin chat, without knowing where anything lives

pointing a commander at a mailbox meant typing its path, which meant a file
browser and an account allowed to walk the disk. only the owner has that — so
"watch my email and tell me when X" was a super-user feature, in a product whose
whole point is that it should not be.

every plugin chat already mounts `ChatHeader` with a `chatContext`, and that
context IS the address: the server turns `{context:'email', contextId:'me@…'}`
into the mailbox's own directory, beside its `emails.db`. so one button on that
header reaches every plugin chat there will ever be, and a new plugin gets it by
declaring the context it already had to declare to have a chat at all. that is
what makes this common to all those chats rather than copied into each.

THE CLIENT SENDS NO PATH, because it has none. a plugin's chats live under
$DATA_PATH/<email>/plugins/<app>/chats and an email chat beside its own database;
both are built server-side and deliberately never sent to the browser. the
resolver moved out of chat.ts into `chat-context.ts` so command-hq uses the SAME
mapping — two copies would be two chances for a commander to watch a directory
its own chat has never been in.

it NAVIGATES rather than creating on the spot. a commander with no first turn has
no transcript, and the grid filters those rows out — so "create it silently"
makes something the person cannot then find. the setup screen is also where the
first turn happens, which is where the job actually gets settled. the button
carries the context there, pre-fills the name, and leaves one button to press.
the reference is shown as what it WATCHES rather than as a path, because there is
no path to show.

caught by testing rather than by reading: `resolveChatContextDir` reads the query
string, and command-hq takes its context in a JSON body — so the first version
resolved every commander to the owner's home. that failure is invisible, since a
commander watching the wrong directory just answers about the wrong thing
forever. the value-taking `chatContextDir` is what command-hq calls.

verified live: context 'email' resolves to email_accounts, plugin/proxy-manager
to its own chats directory.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
This commit is contained in:
2026-08-27 14:32:49 +00:00
co-authored by Claude Opus 5
parent 9ec60d6d75
commit bf8bfa6859
7 changed files with 231 additions and 57 deletions
+58
View File
@@ -0,0 +1,58 @@
import type { Context } from 'hono';
import { getPluginChatsDir, getBotsDir, resolveEmailAccountDir } from '@@/data-path';
// One answer to "which directory is this chat about", for the two routers that need it.
//
// It lived in chat.ts as a private helper until 2026-08-27, when Command HQ grew a button that starts a
// commander from a plugin chat. That commander watches the directory the chat runs in — and the whole
// reason this function exists is that the CLIENT cannot name that directory. Two copies of the mapping
// would be two chances for a commander to end up watching somewhere its chat has never been.
/**
* Which directory a listing is about.
*
* `?context=plugin&contextId=<appName>` resolves server-side, and it exists because the client CANNOT
* name this one: a plugin's chats run in `$DATA_PATH/<email>/plugins/<appName>/chats`, built by
* `resolveChatCwd` and deliberately never sent to the browser. Without this, a plugin can start chats in
* its own directory and then has no way to ask for them back.
*
* The plain `?cwd=` form stays for `/chat`, where the user picks a directory and knows what they picked.
* It is not a hole — `listClaudeSessions` reads that identity's own `~/.claude/projects`, so naming
* another directory shows you your own conversations elsewhere, never anybody else's.
*/
/**
* The same mapping, from values rather than from a request.
*
* Command HQ takes the context in a JSON BODY — it is creating a commander, not listing anything — and
* the request-reading version silently fell through to `who.home` for it, because there was nothing in
* the query string. A commander watching the wrong directory is not an error anywhere: it just answers
* questions about the wrong thing forever.
*/
export const chatContextDir = (
context: string | undefined,
contextId: string | undefined,
who: { home: string; email: string },
): string => {
const id = contextId?.trim();
if (context === 'plugin' && id) return getPluginChatsDir(who.email, id);
if (context === 'bots') return getBotsDir(who.email);
if (context === 'email') return resolveEmailAccountDir(who.email, id);
return who.home;
};
export const resolveChatContextDir = (ctx: Context, who: { home: string; email: string }): string => {
const context = ctx.req.query('context');
const contextId = ctx.req.query('contextId')?.trim();
if (context === 'plugin' && contextId) return getPluginChatsDir(who.email, contextId);
// Every bot shares one directory, so this needs no id — the context IS the address.
if (context === 'bots') return getBotsDir(who.email);
// An email chat runs in the MAILBOX's directory, beside `emails.db`. `contextId` is the account picked on
// screen, and is optional: without one the resolver takes the most recently synced account.
//
// This branch was missing, and the failure was quiet in the way that costs the most time — the context
// fell through to `who.home`, so the picker above an email composer listed the home's conversations. A
// message sent into the mailbox then could not be found in the list directly above it. Same resolver as
// the socket, so the turn and the list cannot disagree about where the chat lives.
if (context === 'email') return resolveEmailAccountDir(who.email, contextId);
return ctx.req.query('cwd')?.trim() || who.home;
};
+8 -33
View File
@@ -91,6 +91,7 @@ export async function chatIdentity(user: { id: number; email: string }): Promise
// owner's home and Linux account. It is the same blocker, not a new one, but it is easy to miss because it
// lives in a sidecar rather than in this file. Whoever does the identity work: all three move together.
import { registerAgentPanelRoutes } from './agent-panels-routes';
import { resolveChatContextDir } from './chat-context';
export const chatRouter = createRouter();
@@ -117,35 +118,9 @@ export const chatRouter = createRouter();
// 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.
/**
* Which directory a listing is about.
*
* `?context=plugin&contextId=<appName>` resolves server-side, and it exists because the client CANNOT
* name this one: a plugin's chats run in `$DATA_PATH/<email>/plugins/<appName>/chats`, built by
* `resolveChatCwd` and deliberately never sent to the browser. Without this, a plugin can start chats in
* its own directory and then has no way to ask for them back.
*
* The plain `?cwd=` form stays for `/chat`, where the user picks a directory and knows what they picked.
* It is not a hole — `listClaudeSessions` reads that identity's own `~/.claude/projects`, so naming
* another directory shows you your own conversations elsewhere, never anybody else's.
*/
const cwdOf = (ctx: Context, who: { home: string; email: string }): string => {
const context = ctx.req.query('context');
const contextId = ctx.req.query('contextId')?.trim();
if (context === 'plugin' && contextId) return getPluginChatsDir(who.email, contextId);
// Every bot shares one directory, so this needs no id — the context IS the address.
if (context === 'bots') return getBotsDir(who.email);
// An email chat runs in the MAILBOX's directory, beside `emails.db`. `contextId` is the account picked on
// screen, and is optional: without one the resolver takes the most recently synced account.
//
// This branch was missing, and the failure was quiet in the way that costs the most time — the context
// fell through to `who.home`, so the picker above an email composer listed the home's conversations. A
// message sent into the mailbox then could not be found in the list directly above it. Same resolver as
// the socket, so the turn and the list cannot disagree about where the chat lives.
if (context === 'email') return resolveEmailAccountDir(who.email, contextId);
return ctx.req.query('cwd')?.trim() || who.home;
};
// selects there too. The resolver moved to `chat-context.ts` when Command HQ needed the same answer —
// a commander started from a plugin chat watches the directory that chat runs in, and only the server
// can turn a context into a path.
// GET /chat/pwds — the default /chat dir plus every directory that already has Claude sessions.
chatRouter.get('/pwds', async (ctx) => {
@@ -158,7 +133,7 @@ chatRouter.get('/pwds', async (ctx) => {
chatRouter.get('/sessions', async (ctx) => {
const user = ctx.get('user');
const who = await chatIdentity(user);
const cwd = cwdOf(ctx, who);
const cwd = resolveChatContextDir(ctx, who);
// `?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,
@@ -242,7 +217,7 @@ 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);
const cwd = resolveChatContextDir(ctx, who);
// 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
@@ -455,7 +430,7 @@ chatRouter.get('/live', async (ctx) => {
chatRouter.delete('/sessions/:id', async (ctx) => {
const who = await chatIdentity(ctx.get('user'));
const id = ctx.req.param('id');
const cwd = cwdOf(ctx, who);
const cwd = resolveChatContextDir(ctx, who);
if (isOpenCodeSessionId(id) && !who.isOwner) return ctx.text('Not found', 404);
if (isOpenCodeSessionId(id)) {
if (!(await deleteOpenCodeSession(id))) return ctx.text('Not found', 404);
@@ -520,7 +495,7 @@ chatRouter.delete('/sessions/:id', async (ctx) => {
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);
const cwd = resolveChatContextDir(ctx, who);
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);