From 9a79a76b95b70a6ca0ca45f40b4b60ed6fc745a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Sat, 25 Jul 2026 11:06:17 +0000 Subject: [PATCH] chat: inject an Officer system prompt telling OpenCode its working directory (step 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OpenCode can't set a real per-session cwd (every session runs in the fixed server's dir), so we tell the model its working directory via a system prompt appended to OpenCode's own — sent as a system message, so it never appears in the visible chat (verified against source + live). Claude doesn't need this (it honors cwd natively). - client.postMessage(…, system?) forwards a `system` string on the message. - send-opencode builds the Officer prompt from `workingDir` and sends it every turn. - websocket: workingDir = the resolved cwd, except the general /chat (whose cwd is the claude_sessions grouping placeholder) uses the user's home. Verified live: with the prompt, the model reports the injected dir as its cwd. Co-Authored-By: Claude Opus 4.8 --- src/servers/api/chat/opencode/client.ts | 16 +++++++++++++--- src/servers/api/chat/websocket.ts | 7 ++++++- src/servers/channels/send-opencode.ts | 18 ++++++++++++++++-- 3 files changed, 35 insertions(+), 6 deletions(-) diff --git a/src/servers/api/chat/opencode/client.ts b/src/servers/api/chat/opencode/client.ts index 14e69c82..83f80e73 100644 --- a/src/servers/api/chat/opencode/client.ts +++ b/src/servers/api/chat/opencode/client.ts @@ -117,11 +117,21 @@ class ServerConnection { return session.id; } - async postMessage(sessionId: string, providerID: string, modelID: string, text: string): Promise { - await this.postJson(`/session/${sessionId}/message`, { + async postMessage( + sessionId: string, + providerID: string, + modelID: string, + text: string, + system?: string, + ): Promise { + // `system` is appended to OpenCode's built-in system prompt (additive, not an override) and is + // sent to the LLM as a system message — it never appears as a visible chat part. + const body: Record = { model: { providerID, modelID }, parts: [{ type: 'text', text }], - }); + }; + if (system) body.system = system; + await this.postJson(`/session/${sessionId}/message`, body); } async abort(sessionId: string): Promise { diff --git a/src/servers/api/chat/websocket.ts b/src/servers/api/chat/websocket.ts index 815dea82..790136ea 100644 --- a/src/servers/api/chat/websocket.ts +++ b/src/servers/api/chat/websocket.ts @@ -4,7 +4,7 @@ import type { ClientMessage, ServerMessage, Message, ChatEvent } from './types'; import { sessionManager } from './session-manager'; import { sendClaudeCodeStreaming } from '@@/channels/send-claude-code'; import { sendOpenCodeStreaming } from '@@/channels/send-opencode'; -import { ensureClaudeSessionsCwd } from './claude-sessions'; +import { ensureClaudeSessionsCwd, getClaudeSessionsCwd } from './claude-sessions'; import * as sidecar from '@@/sidecar-registry'; import { join } from 'path'; import { getHomeDirForRole, getEmailAccountsDir } from '../../../servers/data-path'; @@ -459,6 +459,10 @@ async function handleOpenCodeChat( const onEvent = createEventHandler(sessionId, model, cwd); + // The dir the OpenCode model should treat as its cwd (via the Officer system prompt). For the general + // /chat, the resolved cwd is just the claude_sessions grouping placeholder, so use the user's home. + const workingDir = cwd === getClaudeSessionsCwd(email) ? getHomeDirForRole(email, ws.data.role) : cwd; + try { const handle = await sendOpenCodeStreaming({ userId, @@ -467,6 +471,7 @@ async function handleOpenCodeChat( prompt: effectivePrompt, sessionKey: sessionId, cwd, + workingDir, model, role: ws.data.role, resumeSessionId: msg.resumeSessionId, diff --git a/src/servers/channels/send-opencode.ts b/src/servers/channels/send-opencode.ts index 2561739d..040ca734 100644 --- a/src/servers/channels/send-opencode.ts +++ b/src/servers/channels/send-opencode.ts @@ -14,7 +14,8 @@ type OpenCodeStreamingParams = { username: string; prompt: string; sessionKey: string; - cwd?: string; + cwd?: string; // logical cwd for session tagging/listing (metadata.officer.cwd) + workingDir?: string; // the dir the model should treat as its cwd (Officer system prompt) model?: string; role?: string; resumeSessionId?: string; @@ -32,6 +33,18 @@ function splitModel(model: string): { providerID: string; modelID: string } { return { providerID: model.slice(0, slash), modelID: model.slice(slash + 1) }; } +// OpenCode can't set a real per-session cwd (every session runs in the fixed server's dir), so we tell +// the model its working directory via an appended, non-visible system prompt. Sent on every turn. +function officerSystemPrompt(cwd: string): string { + return [ + `Session working directory: ${cwd}`, + '', + `For this session, treat \`${cwd}\` as your current working directory. Resolve relative paths, globs, and file references against it, and run shell commands from inside it (\`cd\` into it, or use absolute paths beneath it). When the user says "here", "this directory", or gives a relative path, they mean a location within \`${cwd}\`.`, + '', + `Unless the user explicitly directs you elsewhere, keep your work focused on \`${cwd}\`, its files, and its subdirectories.`, + ].join('\n'); +} + export async function sendOpenCodeStreaming(params: OpenCodeStreamingParams): Promise { logger.info('OpenCode streaming exec', { sessionKey: params.sessionKey, model: params.model }); @@ -60,9 +73,10 @@ export async function sendOpenCodeStreaming(params: OpenCodeStreamingParams): Pr unsub = conn.subscribe(sessionId, mapper); const { providerID, modelID } = splitModel(params.model ?? ''); + const system = params.workingDir ? officerSystemPrompt(params.workingDir) : undefined; // Fire the turn; assistant tokens + tool calls stream back over the SSE subscription above. - conn.postMessage(sessionId, providerID, modelID, params.prompt).catch((err) => { + conn.postMessage(sessionId, providerID, modelID, params.prompt, system).catch((err) => { logger.error('OpenCode postMessage failed', { sessionKey: params.sessionKey, error: String(err) }); params.onEvent({ type: 'error', message: 'Failed to send message to OpenCode' }); unsub();