diff --git a/src/servers/api/chat/chat.ts b/src/servers/api/chat/chat.ts index e28f428f..ddd51d69 100644 --- a/src/servers/api/chat/chat.ts +++ b/src/servers/api/chat/chat.ts @@ -1,3 +1,4 @@ +import { getPluginChatsDir } from '@@/data-path'; import type { Context } from 'hono'; import { emitToSession, sessionKeyForTranscript } from './websocket'; import { pauseBackgroundTask, restartBackgroundTask } from './task-restart'; @@ -108,7 +109,24 @@ export const chatRouter = createRouter(); // 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; +/** + * 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); + return ctx.req.query('cwd')?.trim() || who.home; +}; // GET /chat/pwds — the default /chat dir plus every directory that already has Claude sessions. chatRouter.get('/pwds', async (ctx) => { @@ -121,7 +139,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.home); + const cwd = cwdOf(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, @@ -205,7 +223,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.home); + const cwd = cwdOf(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 @@ -418,7 +436,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.home); + const cwd = cwdOf(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); @@ -457,7 +475,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.home); + const cwd = cwdOf(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);