diff --git a/src/servers/api/chat/chat-context.ts b/src/servers/api/chat/chat-context.ts new file mode 100644 index 00000000..cb43719b --- /dev/null +++ b/src/servers/api/chat/chat-context.ts @@ -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=` resolves server-side, and it exists because the client CANNOT + * name this one: a plugin's chats run in `$DATA_PATH//plugins//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; +}; diff --git a/src/servers/api/chat/chat.ts b/src/servers/api/chat/chat.ts index 4b121ebb..0a749648 100644 --- a/src/servers/api/chat/chat.ts +++ b/src/servers/api/chat/chat.ts @@ -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=` resolves server-side, and it exists because the client CANNOT - * name this one: a plugin's chats run in `$DATA_PATH//plugins//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/ 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); diff --git a/src/servers/api/command-hq/router.ts b/src/servers/api/command-hq/router.ts index 1e40c8f3..d737524e 100644 --- a/src/servers/api/command-hq/router.ts +++ b/src/servers/api/command-hq/router.ts @@ -29,6 +29,7 @@ import { type ClaudeSessionSummary, } from '../chat/claude-sessions'; import { collectRunningTasks, startClaudeTurn, COMMANDER_DEFAULT_MODEL } from '../chat/websocket'; +import { chatContextDir } from '../chat/chat-context'; import { listCommandHqCategories, createCommandHqCategory, @@ -527,13 +528,33 @@ commandHqRouter.get('/commanders/:sessionId/fires', async (ctx) => { commandHqRouter.post('/commanders', async (ctx) => { const user = ctx.get('user'); const who = await commandHqIdentity(user); - const body = await ctx.req - .json<{ name?: string; referencePath?: string; categoryId?: number | null }>() - .catch(() => ({}) as { name?: string; referencePath?: string; categoryId?: number | null }); + type Body = { + name?: string; + referencePath?: string; + categoryId?: number | null; + /** A chat context to watch, instead of a path — see below. */ + context?: string; + contextId?: string; + }; + const body = await ctx.req.json().catch(() => ({}) as Body); const name = body.name?.trim(); - const referencePath = body.referencePath?.trim(); if (!name) throw errors.BAD_REQUEST('name is required'); - if (!referencePath) throw errors.BAD_REQUEST('referencePath is required'); + + /* + A commander started FROM a chat watches the directory that chat runs in. + + The client sends the context it already has — `{context:'email', contextId:'me@…'}` — and never a + path, because it cannot know one: a plugin's chats live under `$DATA_PATH//plugins//chats` + and an email chat beside its own `emails.db`, both built server-side and deliberately not sent to the + browser. `resolveChatContextDir` is the same function the chat router uses, so a commander cannot end + up watching a directory its own chat has never been in. + + This is also what makes the feature reachable by somebody who is not a super user. Pointing a + commander at a mailbox previously meant knowing its path and typing it, which meant a file browser + and an account allowed to walk the disk. The button asks for neither. + */ + const referencePath = body.context ? chatContextDir(body.context, body.contextId, who) : body.referencePath?.trim(); + if (!referencePath) throw errors.BAD_REQUEST('referencePath or context is required'); const row = await createCommander({ userId: user.id, name, referencePath, categoryId: body.categoryId ?? null }); const { dir, ownershipError, credentialError } = await createCommanderDirectory(who.email, row.dirName, { diff --git a/src/workspaces/officerdev/src/apps/Chat/ChatHeader.tsx b/src/workspaces/officerdev/src/apps/Chat/ChatHeader.tsx index 9fbad8e7..a287fa36 100644 --- a/src/workspaces/officerdev/src/apps/Chat/ChatHeader.tsx +++ b/src/workspaces/officerdev/src/apps/Chat/ChatHeader.tsx @@ -12,6 +12,7 @@ import { useWorkspace, cwdToPath } from '../../components/Workspace'; import { usePanelSession } from './use-panel-session'; import type { ChatMessage } from './types'; import { useTranslation } from '../../locales'; +import { WatchWithCommanderButton } from './WatchWithCommanderButton'; export type ChatSessionSelection = { sessionId: string | null; @@ -45,9 +46,15 @@ const title = (s: { title: string }) => s.title || '(untitled)'; export const ChatHeader = ({ panelId, chatContext, + commanderLabel, }: { panelId?: string; chatContext?: { context: string; contextId?: string } | null; + /** + * What a commander started from here would be watching, in the plugin's own words — "Email", "Proxy + * hosts". Absent means no button: a chat with nothing to name is a chat with nothing to watch. + */ + commanderLabel?: string; }) => { const { t, tUi } = useTranslation(); const { workspace, cwd } = useWorkspace(); @@ -181,6 +188,10 @@ export const ChatHeader = ({ {cwd} + {/* Every plugin chat mounts this header with a context, and that context IS the address — so one + button here reaches all of them, and a new plugin gets it by declaring what it already had to + declare to have a chat at all. */} + {chatContext && commanderLabel && } ); }; diff --git a/src/workspaces/officerdev/src/apps/Chat/WatchWithCommanderButton.tsx b/src/workspaces/officerdev/src/apps/Chat/WatchWithCommanderButton.tsx new file mode 100644 index 00000000..b03ebf8f --- /dev/null +++ b/src/workspaces/officerdev/src/apps/Chat/WatchWithCommanderButton.tsx @@ -0,0 +1,62 @@ +import { Link } from 'react-router'; +import { Radar } from 'lucide-react'; +import { commandHqNewPath } from '../CommandHQ/command-hq-routes'; + +/** + * Turn this chat into a commander watching the same place. + * + * ── Why it lives on the chat header ── + * + * Every plugin chat mounts `ChatHeader` with a `chatContext`, and that context IS the address — the + * server turns `{context:'email', contextId:'me@…'}` into the mailbox's own directory. So one button + * here 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. + * + * ── Why the client sends no path ── + * + * It has none. A plugin's chats live under `$DATA_PATH//plugins//chats` and an email chat + * beside its own `emails.db`; both are built server-side and deliberately never sent to the browser. + * The request carries the context and the server resolves it with the same function the chat router + * uses, so a commander cannot end up watching a directory its own chat has never been in. + * + * ── Why this matters beyond a shortcut ── + * + * Pointing a commander at a mailbox used to mean knowing its path and typing it, which meant a file + * browser and an account allowed to walk the disk. Only the owner has that. This asks for neither, so + * "watch my email and tell me when X" stops being a super-user feature. + */ +export const WatchWithCommanderButton = ({ + chatContext, + label, +}: { + chatContext: { context: string; contextId?: string }; + label: string; +}) => { + /* + It NAVIGATES rather than creating on the spot, and that is not timidity. + + A commander with no first turn has no transcript, and the Command HQ grid filters those rows out — + so "create it silently" would make something the person cannot then find. The setup screen is also + where the first turn happens, which is where the job actually gets settled. So this carries the + context there, pre-fills the name, and leaves one button to press. + + A `` rather than a click handler: it is a navigation, so it should middle-click and be + bookmarkable like one. + */ + const search = new URLSearchParams({ + context: chatContext.context, + ...(chatContext.contextId ? { contextId: chatContext.contextId } : {}), + label, + }); + + return ( + + + Commander + + ); +}; diff --git a/src/workspaces/officerdev/src/apps/CommandHQ/CommandHqDetail.tsx b/src/workspaces/officerdev/src/apps/CommandHQ/CommandHqDetail.tsx index 555675ac..651adc71 100644 --- a/src/workspaces/officerdev/src/apps/CommandHQ/CommandHqDetail.tsx +++ b/src/workspaces/officerdev/src/apps/CommandHQ/CommandHqDetail.tsx @@ -165,11 +165,14 @@ const CommanderSetup = ({ name, onName, referencePath, + contextLabel, onPickReferencePath, }: { name: string; onName: (next: string) => void; referencePath: string | null; + /** Named instead of picked — the reference is a context the server resolves. See `NewCommanderSetup`. */ + contextLabel?: string | null; onPickReferencePath: () => void; }) => { const { t, tUi } = useTranslation(); @@ -197,19 +200,34 @@ const CommanderSetup = ({ {t('commandHq.referencePath')} - - {t('commandHq.referencePathHint')} + {/* A context is named, not chosen. There is no directory to show — the browser was never told + one — so this states WHAT is watched and leaves the path to the server. */} + {contextLabel ? ( +
+ + {contextLabel} +
+ ) : ( + + )} + + {contextLabel + ? 'Resolved by the server — this chat already knows where it lives.' + : t('commandHq.referencePathHint')} + @@ -231,11 +249,24 @@ function NewCommanderSetup({ category: number | 'none' | undefined; onCreated: (commander: CommandHqCommanderCreated) => void; }) { - const [name, setName] = useState(''); + /* + Arrived from a plugin chat, carrying what it is about rather than where it is. + + `?context=email&contextId=me@…` is the same pair that chat already speaks, and the SERVER turns it + into a directory — the browser has never been told one. So the picker is replaced by a label, the + reference is resolved at create time, and somebody who cannot walk the disk can still point a + commander at their own mailbox. Without a context this is the ordinary flow, unchanged. + */ + const [searchParams] = useSearchParams(); + const context = searchParams.get('context'); + const contextId = searchParams.get('contextId'); + const contextLabel = searchParams.get('label'); + + const [name, setName] = useState(contextLabel ? (contextId ? `${contextLabel} — ${contextId}` : contextLabel) : ''); const [referencePath, setReferencePath] = useState(null); const [pickerOpen, setPickerOpen] = useState(false); const [creating, setCreating] = useState(false); - const ready = name.trim().length > 0 && !!referencePath; + const ready = name.trim().length > 0 && (!!referencePath || !!context); const createCommander = useCreateCommander(); const onStart = async () => { @@ -244,7 +275,8 @@ function NewCommanderSetup({ try { const commander = await createCommander.mutateAsync({ name: name.trim(), - referencePath: referencePath!, + // One or the other: a path the person picked, or a context only the server can resolve. + ...(context ? { context, ...(contextId ? { contextId } : {}) } : { referencePath: referencePath! }), categoryId: typeof category === 'number' ? category : undefined, }); onCreated(commander); @@ -260,11 +292,14 @@ function NewCommanderSetup({ name={name} onName={setName} referencePath={referencePath} + contextLabel={context ? (contextLabel ?? context) : null} onPickReferencePath={() => setPickerOpen(true)} /> void onStart()} /> diff --git a/src/workspaces/officerdev/src/apps/CommandHQ/useCommandHq.ts b/src/workspaces/officerdev/src/apps/CommandHQ/useCommandHq.ts index 9bac8ba8..1e44e447 100644 --- a/src/workspaces/officerdev/src/apps/CommandHQ/useCommandHq.ts +++ b/src/workspaces/officerdev/src/apps/CommandHQ/useCommandHq.ts @@ -26,8 +26,20 @@ export function useCreateCommander() { const qc = useQueryClient(); return useMutation({ - mutationFn: (input: { name: string; referencePath: string; categoryId?: number | null }) => - post('/command-hq/commanders', input), + /* + A path OR a context, never both, and the union says so rather than making both optional. + + `referencePath` is what the person picked in the directory picker. `context` is what a plugin chat + carries — `{context:'email', contextId:'me@…'}` — which the SERVER resolves to a directory, because + the browser is never told one. Optional-both would compile for a call that sends neither, and the + route answers that with a 400 nobody sees until it happens. + */ + mutationFn: ( + input: { name: string; categoryId?: number | null } & ( + | { referencePath: string; context?: never; contextId?: never } + | { context: string; contextId?: string; referencePath?: never } + ), + ) => post('/command-hq/commanders', input), onSuccess: () => qc.invalidateQueries({ queryKey: ['command-hq', 'commanders'] }), }); }