Both harnesses now use the resolved chat cwd as their working directory: Claude runs in it natively, and OpenCode is told the same via its system prompt. Removes the earlier special-case that pointed OpenCode at the user's home for /chat, so `workingDir` collapses into `cwd` — which now both tags the session (metadata.officer.cwd) and drives the Officer system prompt. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
91 lines
4.0 KiB
TypeScript
91 lines
4.0 KiB
TypeScript
import type { ChatEvent } from '@@/api/chat/types';
|
|
import { logger } from '@@/api/chat/logger';
|
|
import { ensureServer } from '@@/api/chat/opencode/server-manager';
|
|
import { getConnection } from '@@/api/chat/opencode/client';
|
|
import { createEventMapper } from '@@/api/chat/opencode/event-mapper';
|
|
import { getOpenCodeSession, setOpenCodeSession } from '@@/api/chat/opencode/state';
|
|
|
|
// The OpenCode analog of send-claude-code.ts's streaming path. Drives a turn against a warm
|
|
// `opencode serve` over HTTP + SSE, mapping events to the shared ChatEvent contract.
|
|
|
|
type OpenCodeStreamingParams = {
|
|
userId: number;
|
|
email: string;
|
|
username: string;
|
|
prompt: string;
|
|
sessionKey: string;
|
|
cwd?: string; // the session's working dir: tags it (metadata.officer.cwd) + told to the model (system prompt)
|
|
model?: string;
|
|
role?: string;
|
|
resumeSessionId?: string;
|
|
onEvent: (event: ChatEvent) => void;
|
|
};
|
|
|
|
type OpenCodeStreamingHandle = {
|
|
kill: () => void;
|
|
};
|
|
|
|
/** Split an OpenCode model id (`providerID/modelID`, e.g. `opencode/claude-opus-4-8`). */
|
|
function splitModel(model: string): { providerID: string; modelID: string } {
|
|
const slash = model.indexOf('/');
|
|
if (slash <= 0) return { providerID: 'opencode', modelID: model };
|
|
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<OpenCodeStreamingHandle> {
|
|
logger.info('OpenCode streaming exec', { sessionKey: params.sessionKey, model: params.model });
|
|
|
|
const { baseUrl } = await ensureServer();
|
|
const conn = getConnection(baseUrl);
|
|
|
|
// Resolve the OpenCode session: a known mapping, or — when resuming from history — the sessionKey is
|
|
// itself the OpenCode session id (`ses_…`); otherwise create a new one.
|
|
let opencodeSessionId =
|
|
getOpenCodeSession(params.sessionKey) ??
|
|
(params.sessionKey.startsWith('ses_') ? params.sessionKey : undefined) ??
|
|
params.resumeSessionId;
|
|
if (!opencodeSessionId) {
|
|
// Tag the session with its logical cwd so it can be listed in the right place (OpenCode has no
|
|
// per-session directory — every session runs in the fixed server's cwd).
|
|
opencodeSessionId = await conn.createSession(params.cwd ? { officer: { cwd: params.cwd } } : undefined);
|
|
}
|
|
setOpenCodeSession(params.sessionKey, opencodeSessionId);
|
|
const sessionId = opencodeSessionId;
|
|
|
|
let unsub = () => {};
|
|
const mapper = createEventMapper((event: ChatEvent) => {
|
|
params.onEvent(event);
|
|
if (event.type === 'result' || event.type === 'error') unsub();
|
|
});
|
|
unsub = conn.subscribe(sessionId, mapper);
|
|
|
|
const { providerID, modelID } = splitModel(params.model ?? '');
|
|
const system = params.cwd ? officerSystemPrompt(params.cwd) : undefined;
|
|
|
|
// Fire the turn; assistant tokens + tool calls stream back over the SSE subscription above.
|
|
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();
|
|
});
|
|
|
|
return {
|
|
kill: () => {
|
|
void conn.abort(sessionId);
|
|
unsub();
|
|
},
|
|
};
|
|
}
|