From 4746c9053cf688c67af588c70aec1829b74593dd Mon Sep 17 00:00:00 2001 From: VooDisss <41582720+VooDisss@users.noreply.github.com> Date: Thu, 9 Apr 2026 23:48:29 +0300 Subject: [PATCH] feat(plugin): add session title rename tool Add a CodeNomad plugin tool for renaming the active session title and wire it through the existing plugin bridge to the workspace OpenCode instance. This gives the assistant a dedicated, explicit tool for session renaming without introducing a second title storage path or any UI-only workaround. The implementation follows the repo's existing plugin architecture: the plugin registers a new ename_session tool, validates the active session context, and calls a CodeNomad server plugin endpoint. On the server side, that endpoint validates the payload, reuses the workspace instance port and authorization header, and forwards the request to OpenCode using the worktree-aware directory context from the tool execution environment. This commit also fixes the initial proxy bug discovered during validation. OpenCode's generated SDK updates sessions with PATCH /session/{sessionID}, but the first proxy implementation forwarded the request as POST, which allowed the tool to report success without actually renaming the session. Switching the forwarded request to PATCH aligns the bridge with the real SDK contract and makes session title updates apply correctly. Description persistence and UI display remain intentionally out of scope for this checkpoint. The goal of this snapshot is to make title renaming reliable end-to-end with the smallest correct change and without touching unrelated session metadata behavior. --- packages/opencode-config/plugin/codenomad.ts | 3 ++ .../opencode-config/plugin/lib/session.ts | 49 +++++++++++++++++++ packages/server/src/server/routes/plugin.ts | 49 +++++++++++++++++++ 3 files changed, 101 insertions(+) create mode 100644 packages/opencode-config/plugin/lib/session.ts diff --git a/packages/opencode-config/plugin/codenomad.ts b/packages/opencode-config/plugin/codenomad.ts index 08515dd8a..ec4a2af25 100644 --- a/packages/opencode-config/plugin/codenomad.ts +++ b/packages/opencode-config/plugin/codenomad.ts @@ -1,6 +1,7 @@ import type { PluginInput } from "@opencode-ai/plugin" import { createCodeNomadClient, getCodeNomadConfig } from "./lib/client" import { createBackgroundProcessTools } from "./lib/background-process" +import { createSessionTools } from "./lib/session" let voiceModeEnabled = false @@ -8,6 +9,7 @@ export async function CodeNomadPlugin(input: PluginInput) { const config = getCodeNomadConfig() const client = createCodeNomadClient(config) const backgroundProcessTools = createBackgroundProcessTools(config, { baseDir: input.directory }) + const sessionTools = createSessionTools(config) await client.startEvents((event) => { if (event.type === "codenomad.ping") { @@ -29,6 +31,7 @@ export async function CodeNomadPlugin(input: PluginInput) { return { tool: { ...backgroundProcessTools, + ...sessionTools, }, async "chat.message"(_input: { sessionID: string }, output: { message: { system?: string } }) { if (!voiceModeEnabled) { diff --git a/packages/opencode-config/plugin/lib/session.ts b/packages/opencode-config/plugin/lib/session.ts new file mode 100644 index 000000000..e1777d1eb --- /dev/null +++ b/packages/opencode-config/plugin/lib/session.ts @@ -0,0 +1,49 @@ +import { tool } from "@opencode-ai/plugin/tool" +import { createCodeNomadRequester, type CodeNomadConfig } from "./request" + +type SessionRenameResponse = { + sessionID: string + title: string +} + +export function createSessionTools(config: CodeNomadConfig) { + const requester = createCodeNomadRequester(config) + + const request = async (path: string, init?: RequestInit): Promise => { + return requester.requestJson(path, init) + } + + return { + rename_session: tool({ + description: + "Rename the current session when the user asks to change the chat title. Use a short descriptive title that reflects the current task.", + args: { + title: tool.schema + .string() + .describe("New session title, kept short and descriptive, for example 'Fix login bug' or 'Add session rename tool'"), + }, + async execute(args, context) { + const sessionID = context.sessionID + if (!sessionID) { + return "Error: No active session is available for renaming." + } + + const trimmedTitle = args.title.trim() + if (!trimmedTitle) { + return "Error: Session title cannot be empty." + } + + const result = await request("/session/title", { + method: "POST", + body: JSON.stringify({ + sessionID, + directory: context.directory, + title: trimmedTitle, + }), + }) + + return `Renamed session ${result.sessionID} to \"${result.title}\".` + }, + }), + } +} diff --git a/packages/server/src/server/routes/plugin.ts b/packages/server/src/server/routes/plugin.ts index daa7630e7..5f15de41f 100644 --- a/packages/server/src/server/routes/plugin.ts +++ b/packages/server/src/server/routes/plugin.ts @@ -27,6 +27,12 @@ const VoiceModeStateSchema = z.object({ connectionId: z.string().trim().min(1), }) +const SessionTitleUpdateSchema = z.object({ + sessionID: z.string().trim().min(1), + directory: z.string().trim().min(1).optional(), + title: z.string().trim().min(1), +}) + export function registerPluginRoutes(app: FastifyInstance, deps: RouteDeps) { app.get<{ Params: { id: string } }>("/workspaces/:id/plugin/events", (request, reply) => { const workspace = deps.workspaceManager.get(request.params.id) @@ -92,6 +98,49 @@ export function registerPluginRoutes(app: FastifyInstance, deps: RouteDeps) { return } + if (normalized === "session/title" && request.method === "POST") { + const parsed = SessionTitleUpdateSchema.parse(request.body ?? {}) + const port = deps.workspaceManager.getInstancePort(workspaceId) + if (!port) { + reply.code(502).send({ error: "Workspace instance is not ready" }) + return + } + + const params = new URLSearchParams() + if (parsed.directory) { + params.set("directory", parsed.directory) + } + + const targetUrl = `http://127.0.0.1:${port}/session/${encodeURIComponent(parsed.sessionID)}${params.size > 0 ? `?${params.toString()}` : ""}` + const headers: Record = { + "content-type": "application/json", + } + + const authorization = deps.workspaceManager.getInstanceAuthorizationHeader(workspaceId) + if (authorization) { + headers.authorization = authorization + } + + const response = await fetch(targetUrl, { + method: "PATCH", + headers, + body: JSON.stringify({ title: parsed.title }), + }) + + if (!response.ok) { + const message = await response.text().catch(() => "") + reply.code(response.status).send({ error: message || `Session update failed with ${response.status}` }) + return + } + + const payload = (await response.json().catch(() => null)) as { id?: string; title?: string } | null + reply.send({ + sessionID: payload?.id ?? parsed.sessionID, + title: payload?.title ?? parsed.title, + }) + return + } + reply.code(404).send({ error: "Unknown plugin endpoint" }) }