mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-08 07:17:39 +00:00
* feat(opencode): support plan mode * feat(opencode): support reasoning effort * feat(opencode): surface context usage in web Bridge OpenCode ACP usage updates into the existing token-count pipeline so the web status bar can show live context and cache information without a separate UI path. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(opencode): restrict plan mode to remote, rollback reasoning effort on failure - Block local OpenCode plan startup (tools not enforced in local path) - Allow remote OpenCode plan only (ACP permission handler denies tools) - Guard web /permission-mode endpoint for local OpenCode plan sessions - Rollback session reasoning effort when OpenCode rejects set_config_option - Wire rollback callback through opencodeLoop to runOpencode closure - Add tests: local plan rejected, remote plan allowed, web guard, effort rollback * fix(web): auto-retry OpenCode models query to populate model selector without refresh - Retry early failures (RPC may still be registering on new sessions) - Poll briefly until availableModels is non-empty - Stop polling once model options are discovered - Add tests for retry/poll/stop policy * fix(opencode): cap model discovery polling --------- Co-authored-by: Cursor <cursoragent@cursor.com>
199 lines
7.7 KiB
TypeScript
199 lines
7.7 KiB
TypeScript
import { logger } from '@/ui/logger';
|
|
import { opencodeLoop } from './loop';
|
|
import { MessageQueue2 } from '@/utils/MessageQueue2';
|
|
import { hashObject } from '@/utils/deterministicJson';
|
|
import { registerKillSessionHandler } from '@/claude/registerKillSessionHandler';
|
|
import type { AgentState } from '@/api/types';
|
|
import type { OpencodeSession } from './session';
|
|
import type { OpencodeMode, PermissionMode } from './types';
|
|
import { bootstrapExistingSession, bootstrapSession } from '@/agent/sessionFactory';
|
|
import { registerLocalHandoffHandler } from '@/agent/localHandoff';
|
|
import { createModeChangeHandler, createRunnerLifecycle, setControlledByUser } from '@/agent/runnerLifecycle';
|
|
import { registerSessionConfigRpc } from '@/agent/sessionConfigRpc';
|
|
import { startOpencodeHookServer } from './utils/startOpencodeHookServer';
|
|
import { formatMessageWithAttachments } from '@/utils/attachmentFormatter';
|
|
import { getInvokedCwd } from '@/utils/invokedCwd';
|
|
|
|
export async function runOpencode(opts: {
|
|
startedBy?: 'runner' | 'terminal';
|
|
startingMode?: 'local' | 'remote';
|
|
permissionMode?: PermissionMode;
|
|
model?: string;
|
|
modelReasoningEffort?: string | null;
|
|
resumeSessionId?: string;
|
|
existingSessionId?: string;
|
|
workingDirectory?: string;
|
|
} = {}): Promise<void> {
|
|
const workingDirectory = opts.workingDirectory ?? getInvokedCwd();
|
|
const startedBy = opts.startedBy ?? 'terminal';
|
|
|
|
logger.debug(`[opencode] Starting with options: startedBy=${startedBy}, startingMode=${opts.startingMode}`);
|
|
|
|
if (startedBy === 'runner' && opts.startingMode === 'local') {
|
|
logger.debug('[opencode] Runner spawn requested with local mode; forcing remote mode');
|
|
opts.startingMode = 'remote';
|
|
}
|
|
|
|
const startingMode: 'local' | 'remote' = opts.startingMode
|
|
?? (startedBy === 'runner' ? 'remote' : 'local');
|
|
|
|
if (opts.permissionMode === 'plan' && startingMode !== 'remote') {
|
|
throw new Error('OpenCode plan mode is only supported in remote mode');
|
|
}
|
|
|
|
const initialState: AgentState = {
|
|
controlledByUser: false
|
|
};
|
|
|
|
// Persist only when the user (or runner) explicitly chose a model on launch.
|
|
// Mid-session selections are persisted by the hub via the set-session-config RPC,
|
|
// not by this initial bootstrap.
|
|
const initialModel = opts.model ?? null;
|
|
const initialModelReasoningEffort = opts.modelReasoningEffort ?? null;
|
|
|
|
const bootstrap = opts.existingSessionId
|
|
? await bootstrapExistingSession({
|
|
sessionId: opts.existingSessionId,
|
|
flavor: 'opencode',
|
|
startedBy,
|
|
workingDirectory
|
|
})
|
|
: await bootstrapSession({
|
|
flavor: 'opencode',
|
|
startedBy,
|
|
workingDirectory,
|
|
agentState: initialState,
|
|
model: initialModel ?? undefined,
|
|
modelReasoningEffort: initialModelReasoningEffort ?? undefined
|
|
});
|
|
const { api, session } = bootstrap;
|
|
|
|
setControlledByUser(session, startingMode);
|
|
|
|
const messageQueue = new MessageQueue2<OpencodeMode>((mode) => hashObject({
|
|
permissionMode: mode.permissionMode,
|
|
model: mode.model ?? null,
|
|
modelReasoningEffort: mode.modelReasoningEffort ?? null
|
|
}));
|
|
|
|
const sessionWrapperRef: { current: OpencodeSession | null } = { current: null };
|
|
let currentPermissionMode: PermissionMode = opts.permissionMode ?? 'default';
|
|
let sessionModel: string | null = initialModel;
|
|
let sessionModelReasoningEffort: string | null = initialModelReasoningEffort;
|
|
const hookServer = await startOpencodeHookServer({
|
|
onEvent: (event) => {
|
|
const currentSession = sessionWrapperRef.current;
|
|
if (!currentSession) {
|
|
return;
|
|
}
|
|
currentSession.emitHookEvent(event);
|
|
}
|
|
});
|
|
const hookUrl = `http://127.0.0.1:${hookServer.port}/hook/opencode`;
|
|
|
|
const lifecycle = createRunnerLifecycle({
|
|
session,
|
|
logTag: 'opencode',
|
|
stopKeepAlive: () => sessionWrapperRef.current?.stopKeepAlive(),
|
|
onAfterClose: () => {
|
|
hookServer.stop();
|
|
}
|
|
});
|
|
|
|
lifecycle.registerProcessHandlers();
|
|
registerKillSessionHandler(session.rpcHandlerManager, lifecycle.cleanupAndExit);
|
|
registerLocalHandoffHandler(session.rpcHandlerManager, lifecycle);
|
|
|
|
const syncSessionMode = () => {
|
|
const sessionInstance = sessionWrapperRef.current;
|
|
if (!sessionInstance) {
|
|
return;
|
|
}
|
|
sessionInstance.setPermissionMode(currentPermissionMode);
|
|
sessionInstance.setModel(sessionModel);
|
|
sessionInstance.setModelReasoningEffort(sessionModelReasoningEffort);
|
|
|
|
// Notify hub immediately so the UI reflects the change without
|
|
// waiting for the next 2s keepalive tick.
|
|
sessionInstance.pushKeepAlive();
|
|
|
|
logger.debug(`[opencode] Synced session config for keepalive: permissionMode=${currentPermissionMode}, model=${sessionModel ?? '(default)'}, modelReasoningEffort=${sessionModelReasoningEffort ?? '(default)'}`);
|
|
};
|
|
|
|
session.onUserMessage((message, localId) => {
|
|
const formattedText = formatMessageWithAttachments(message.content.text, message.content.attachments);
|
|
const mode: OpencodeMode = {
|
|
permissionMode: currentPermissionMode,
|
|
model: sessionModel ?? undefined,
|
|
modelReasoningEffort: sessionModelReasoningEffort
|
|
};
|
|
messageQueue.push(formattedText, mode, localId);
|
|
});
|
|
|
|
session.onCancelQueuedMessage((localId) => {
|
|
const removed = messageQueue.cancelByLocalId(localId);
|
|
logger.debug(`[opencode] cancelByLocalId(${localId}): ${removed ? 'removed' : 'not found (best-effort)'}`);
|
|
return removed;
|
|
});
|
|
|
|
registerSessionConfigRpc<PermissionMode>({
|
|
rpcHandlerManager: session.rpcHandlerManager,
|
|
flavor: 'opencode',
|
|
modelMode: 'nullable',
|
|
modelReasoningEffortMode: 'nullable',
|
|
onApply: (config) => {
|
|
if (config.permissionMode !== undefined) {
|
|
currentPermissionMode = config.permissionMode;
|
|
}
|
|
if (config.model !== undefined) {
|
|
sessionModel = config.model;
|
|
}
|
|
if (config.modelReasoningEffort !== undefined) {
|
|
sessionModelReasoningEffort = config.modelReasoningEffort;
|
|
}
|
|
},
|
|
onAfterApply: syncSessionMode
|
|
});
|
|
|
|
let crashed = false;
|
|
|
|
try {
|
|
await opencodeLoop({
|
|
path: workingDirectory,
|
|
startingMode,
|
|
startedBy,
|
|
messageQueue,
|
|
session,
|
|
api,
|
|
permissionMode: currentPermissionMode,
|
|
model: sessionModel ?? undefined,
|
|
modelReasoningEffort: sessionModelReasoningEffort,
|
|
resumeSessionId: opts.resumeSessionId,
|
|
hookServer,
|
|
hookUrl,
|
|
onModeChange: createModeChangeHandler(session),
|
|
onReasoningEffortRollback: (effort) => {
|
|
sessionModelReasoningEffort = effort;
|
|
},
|
|
onSessionReady: (instance) => {
|
|
sessionWrapperRef.current = instance;
|
|
syncSessionMode();
|
|
}
|
|
});
|
|
} catch (error) {
|
|
crashed = true;
|
|
lifecycle.markCrash(error);
|
|
logger.debug('[opencode] Loop error:', error);
|
|
} finally {
|
|
const localFailure = sessionWrapperRef.current?.localLaunchFailure;
|
|
if (localFailure?.exitReason === 'exit') {
|
|
lifecycle.setExitCode(1);
|
|
lifecycle.setArchiveReason(`Local launch failed: ${localFailure.message.slice(0, 200)}`);
|
|
lifecycle.setSessionEndReason('error');
|
|
} else if (!crashed) {
|
|
lifecycle.setSessionEndReason('completed');
|
|
}
|
|
await lifecycle.cleanupAndExit();
|
|
}
|
|
}
|