mirror of
https://github.com/stablyai/orca.git
synced 2026-09-30 08:03:12 +00:00
* fix(opencode): fail-open plugin setup and distinct opencode2 plugin id setup() threw when OpenCode 2 probed it without a full context, which the TUI reported as an 'orca-opencode-status' plugin failure. Both plugin files also shared one id while living in the same config dir. * fix(opencode): drop unused oxlint-disable in setup fail-open test * test(opencode): pin distinct plugin ids for the shared global config dir Orca installs both family plugins into one global plugins dir, so a shared plugin id makes OpenCode 2 fail the later one with 'Duplicate plugin ID'.
299 lines
15 KiB
TypeScript
299 lines
15 KiB
TypeScript
import {
|
|
getOpenCode2SetupSource,
|
|
getOpenCode2EventNormalizationSource
|
|
} from '../opencode2/status-plugin-setup-source'
|
|
|
|
export function getStatusPluginFactorySource(options: {
|
|
emitSessionStart: boolean
|
|
emitNextEvents?: boolean
|
|
expectedAgent?: 'opencode' | 'opencode2'
|
|
}): string[] {
|
|
const expectedAgent = options.expectedAgent ?? (options.emitNextEvents ? 'opencode2' : 'opencode')
|
|
// Why: opencode and opencode2 share one config dir, so both plugin files load in
|
|
// either binary. Distinct ids keep the loader from reporting a duplicate-id
|
|
// collision as an 'orca-opencode-status' plugin failure.
|
|
const pluginID = expectedAgent === 'opencode2' ? 'orca-opencode2-status' : 'orca-opencode-status'
|
|
return [
|
|
...(options.emitNextEvents ? getOpenCode2EventNormalizationSource() : []),
|
|
'// Why: accept the factory argument as an optional opaque parameter instead',
|
|
'// of destructuring (`async ({ client }) => …`). OpenCode can invoke the',
|
|
'// plugin factory with undefined during startup, which makes the',
|
|
'// destructuring form throw synchronously and crash OpenCode with an opaque',
|
|
'// UnknownError before any event is ever dispatched.',
|
|
'export const OrcaOpenCodeStatusPlugin = async (_ctx) => {',
|
|
` if (process.env.ORCA_OPENCODE_AGENT && process.env.ORCA_OPENCODE_AGENT !== '${expectedAgent}') return {};`,
|
|
' const client = _ctx?.client;',
|
|
' const factoryID = ++nextFactoryID;',
|
|
' activeFactoryIDs.add(factoryID);',
|
|
' let disposed = false;',
|
|
...(options.emitNextEvents ? [' const nextTextByMessageID = new Map();'] : []),
|
|
' return {',
|
|
' event: async ({ event }) => {',
|
|
' if (disposed || !event?.type) return;',
|
|
' const authorityRevision = stateArrivalRevision;',
|
|
' const statusType = getStatusType(event);',
|
|
'',
|
|
' // Why: cache the message role BEFORE the async isChildSession check.',
|
|
' // OpenCode fires message.updated (user) and message.part.updated (text)',
|
|
' // back-to-back; if we awaited isChildSession first, the part.updated',
|
|
' // handler could reach messageRoleById.get(...) while the user message.updated',
|
|
' // is still suspended on that await — so the part would see an empty cache',
|
|
' // and drop the user prompt. Caching is a cheap Map.set with bounded size,',
|
|
' // safe to run even for child sessions (the part POST still filters them).',
|
|
' if (event.type === "message.updated") {',
|
|
' const info = event.properties && event.properties.info;',
|
|
' rememberMessageRole(info && info.id, info && info.role);',
|
|
' }',
|
|
'',
|
|
' const sessionID = event.properties?.sessionID;',
|
|
' const updatedPart = event.properties?.part;',
|
|
...(options.emitNextEvents
|
|
? [
|
|
'',
|
|
' // OpenCode 2 publishes the next-generation event family through the',
|
|
' // same plugin event hook. Convert those events into the existing',
|
|
' // bounded Orca lifecycle and preview posts.',
|
|
' if (event.type === "session.next.prompt.admitted") {',
|
|
' if (!sessionID) return;',
|
|
' if ((await isChildSession(client, sessionID)) !== false) return;',
|
|
' if (disposed || authorityRevision !== stateArrivalRevision || desiredStatus === "waiting") return;',
|
|
' const prompt = event.properties?.prompt?.text;',
|
|
' if (typeof prompt !== "string" || !prompt) return;',
|
|
' await postMessagePart({',
|
|
' role: "user",',
|
|
' text: capMessagePartText(prompt),',
|
|
' messageID: event.properties?.messageID,',
|
|
' sessionID,',
|
|
' }, factoryID);',
|
|
' return;',
|
|
' }',
|
|
' if (event.type === "session.next.text.started") {',
|
|
' if (event.properties?.assistantMessageID) {',
|
|
' if (nextTextByMessageID.size >= 128) nextTextByMessageID.delete(nextTextByMessageID.keys().next().value);',
|
|
' nextTextByMessageID.set(event.properties.assistantMessageID, "");',
|
|
' }',
|
|
' return;',
|
|
' }',
|
|
' if (event.type === "session.next.text.delta") {',
|
|
' const messageID = event.properties?.assistantMessageID;',
|
|
' const delta = event.properties?.delta;',
|
|
' if (typeof messageID !== "string" || typeof delta !== "string") return;',
|
|
' nextTextByMessageID.set(messageID, capMessagePartText((nextTextByMessageID.get(messageID) || "") + delta));',
|
|
' if (nextTextByMessageID.size > 128) nextTextByMessageID.delete(nextTextByMessageID.keys().next().value);',
|
|
' return;',
|
|
' }',
|
|
' if (event.type === "session.next.text.ended") {',
|
|
' if (!sessionID) return;',
|
|
' if ((await isChildSession(client, sessionID)) !== false) return;',
|
|
' const messageID = event.properties?.assistantMessageID;',
|
|
' const text = typeof event.properties?.text === "string"',
|
|
' ? event.properties.text',
|
|
' : (typeof messageID === "string" ? nextTextByMessageID.get(messageID) : "");',
|
|
' if (typeof messageID !== "string" || !text) return;',
|
|
' nextTextByMessageID.delete(messageID);',
|
|
' queueAssistantPart({ role: "assistant", text, messageID, sessionID, authorityRevision, factoryID });',
|
|
' return;',
|
|
' }'
|
|
]
|
|
: []),
|
|
...(options.emitSessionStart
|
|
? [
|
|
' if (event.type === "session.created") {',
|
|
' const info = event.properties?.info;',
|
|
' if (!info?.id || info.parentID) return;',
|
|
' rememberSessionRoot(info.id, info.id);',
|
|
' await enqueueLifecycle(() =>',
|
|
' disposed ? undefined : post("SessionStart", { sessionID: info.id })',
|
|
' );',
|
|
' return;',
|
|
' }',
|
|
''
|
|
]
|
|
: []),
|
|
' if (',
|
|
' event.type === "message.part.updated" &&',
|
|
' updatedPart?.type === "tool" &&',
|
|
' updatedPart.tool === "question" &&',
|
|
' (updatedPart.state?.status === "completed" || updatedPart.state?.status === "error")',
|
|
' ) {',
|
|
' await enqueueLifecycle(async () => {',
|
|
' if (disposed) return;',
|
|
' // Why: stored ownership clears child questions without waiting on',
|
|
' // ancestry lookup even though ordinary child message parts stay hidden.',
|
|
' const rootSessionID = clearQuestionForToolPart(updatedPart, sessionID, factoryID);',
|
|
' if (!rootSessionID) return;',
|
|
' await publishOwnershipChange(factoryID, rootSessionID);',
|
|
' });',
|
|
' return;',
|
|
' }',
|
|
'',
|
|
' if (',
|
|
' event.type === "session.status" ||',
|
|
' event.type === "session.idle" ||',
|
|
' event.type === "session.error" ||',
|
|
' event.type === "permission.asked" ||',
|
|
' event.type === "question.asked" ||',
|
|
' event.type === "permission.replied" ||',
|
|
' event.type === "question.replied" ||',
|
|
` event.type === "question.rejected"${
|
|
options.emitNextEvents
|
|
? ' || event.type === "permission.v2.asked" || event.type === "permission.v2.replied" || event.type === "question.v2.asked" || event.type === "question.v2.replied" || event.type === "question.v2.rejected" || event.type === "session.next.step.started" || event.type === "session.next.tool.called" || event.type === "session.next.tool.progress" || event.type === "session.next.retried"'
|
|
: ''
|
|
}`,
|
|
' ) {',
|
|
' await enqueueLifecycle(() =>',
|
|
` disposed ? undefined : handleLifecycleEvent(client, ${options.emitNextEvents ? 'normalizeNextLifecycleEvent(event)' : 'event'}, factoryID)`,
|
|
' );',
|
|
' return;',
|
|
' }',
|
|
'',
|
|
' if (event.type === "message.part.delta") {',
|
|
' const properties = event.properties || {};',
|
|
' if (',
|
|
' properties.field === "text" &&',
|
|
' typeof properties.delta === "string" &&',
|
|
' properties.delta.length > 0',
|
|
' ) {',
|
|
' await recoverBusyFromDelta(client, sessionID, factoryID);',
|
|
' }',
|
|
' return;',
|
|
' }',
|
|
'',
|
|
' if (sessionID && (await isChildSession(client, sessionID)) !== false) {',
|
|
' return;',
|
|
' }',
|
|
' if (disposed) return;',
|
|
' if (authorityRevision !== stateArrivalRevision) return;',
|
|
' if (desiredStatus === "waiting") return;',
|
|
'',
|
|
' if (event.type === "message.updated") {',
|
|
' // Why: role is already cached above the isChildSession await so the',
|
|
' // back-to-back message.part.updated for the same messageID is not',
|
|
' // racing against this handler. Nothing more to do here — return to',
|
|
' // avoid falling through to the part/session handlers below.',
|
|
' return;',
|
|
' }',
|
|
'',
|
|
' if (event.type === "message.part.updated") {',
|
|
' // Why: a TextPart carries the actual user prompt or assistant reply',
|
|
' // text. Skip non-text parts (tool, reasoning, file, …) so we only',
|
|
' // forward what the dashboard renders. Role came from the earlier',
|
|
' // message.updated event; if we never saw one (e.g. plugin loaded',
|
|
' // mid-turn) the role is unknown, and mislabeling the part — a user',
|
|
' // prompt displayed as the assistant reply, or vice versa — is worse',
|
|
' // than silently dropping a single in-flight text chunk. The next',
|
|
' // message.updated event will re-seed the role cache, so subsequent',
|
|
' // parts in the same session flow normally.',
|
|
' const part = event.properties && event.properties.part;',
|
|
' if (!part || part.type !== "text" || !part.text) return;',
|
|
' // Why: OpenCode injects a finished background task back into the',
|
|
' // parent turn as a synthetic `<task id=…>` text part. It is machinery,',
|
|
' // not what the human typed or the agent replied — OpenCode hides it',
|
|
' // from its own prompt too — so it must not replace the pane preview.',
|
|
' if (part.synthetic === true) return;',
|
|
' const role = messageRoleById.get(part.messageID);',
|
|
' if (!role) return;',
|
|
' if (role === "user") {',
|
|
' // Why: user prompts arrive as a single event, not a stream — post',
|
|
' // immediately (still capped) so the throttle slot stays free for',
|
|
' // the assistant reply that follows within the same window.',
|
|
' await postMessagePart(',
|
|
' { role, text: capMessagePartText(part.text), messageID: part.messageID, sessionID },',
|
|
' factoryID',
|
|
' );',
|
|
' return;',
|
|
' }',
|
|
' queueAssistantPart({',
|
|
' role,',
|
|
' text: part.text,',
|
|
' messageID: part.messageID,',
|
|
' sessionID,',
|
|
' authorityRevision,',
|
|
' factoryID,',
|
|
' });',
|
|
' return;',
|
|
' }',
|
|
'',
|
|
' },',
|
|
' dispose: async () => {',
|
|
' if (disposed) return;',
|
|
' disposed = true;',
|
|
...(options.emitNextEvents ? [' nextTextByMessageID.clear();'] : []),
|
|
' disposingFactoryIDs.add(factoryID);',
|
|
' await enqueueLifecycle(async () => {',
|
|
' // An older MessagePart must settle before disposal publishes the',
|
|
' // replacement state, or its late Working update could win.',
|
|
' while (messagePartPostInFlight) await messagePartPostInFlight;',
|
|
' for (const [sessionID, ownerID] of busyRootOwnerBySessionID) {',
|
|
' if (ownerID === factoryID) busyRootOwnerBySessionID.delete(sessionID);',
|
|
' }',
|
|
' for (const [key, provisional] of provisionalBusyByKey) {',
|
|
' if (provisional.factoryID === factoryID) provisionalBusyByKey.delete(key);',
|
|
' }',
|
|
' for (const [key, busyChild] of busyChildRootByKey) {',
|
|
' if (busyChild.factoryID === factoryID) busyChildRootByKey.delete(key);',
|
|
' }',
|
|
' for (const [key, attention] of pendingAttentionByKey) {',
|
|
' if (attention.factoryID === factoryID) pendingAttentionByKey.delete(key);',
|
|
' }',
|
|
' if (pendingAssistantPart?.factoryID === factoryID) {',
|
|
' if (assistantPartFlushTimer) clearTimeout(assistantPartFlushTimer);',
|
|
' assistantPartFlushTimer = null;',
|
|
' pendingAssistantPart = null;',
|
|
' }',
|
|
' const ownsDeliveredMessagePart = deliveredMessagePartFactoryID === factoryID;',
|
|
' if (desiredFactoryID === factoryID || ownsDeliveredMessagePart) {',
|
|
' clearStatusRetry();',
|
|
' statusRevision += 1;',
|
|
' // A MessagePart may have changed the listener to Working after the',
|
|
' // same lifecycle key was delivered; force that key to be reasserted.',
|
|
' statusDeliveryDirty = ownsDeliveredMessagePart;',
|
|
' busyRecoveryUsed = false;',
|
|
' busyRecoveryEndpointKey = "";',
|
|
' const fallbackFactoryID = Array.from(activeFactoryIDs).find(',
|
|
' (id) => id !== factoryID',
|
|
' );',
|
|
' if (fallbackFactoryID !== undefined) {',
|
|
' await publishAggregateStatus(',
|
|
' fallbackFactoryID,',
|
|
' desiredStatusProperties?.sessionID',
|
|
' );',
|
|
' } else {',
|
|
' // Why: Instance disposal can happen while the PTY stays alive;',
|
|
' // publish a final idle so Orca does not retain a dead owner.',
|
|
' if (!deliveredStatusKey.startsWith("idle:") || ownsDeliveredMessagePart) {',
|
|
' await setStatus(',
|
|
' "idle",',
|
|
' { sessionID: desiredStatusProperties?.sessionID },',
|
|
' factoryID',
|
|
' );',
|
|
' }',
|
|
' clearStatusRetry();',
|
|
' desiredStatus = "idle";',
|
|
' desiredHookEventName = "SessionIdle";',
|
|
' desiredStatusKey = "idle:";',
|
|
' desiredStatusProperties = {};',
|
|
' desiredFactoryID = null;',
|
|
' }',
|
|
' }',
|
|
' activeFactoryIDs.delete(factoryID);',
|
|
' disposingFactoryIDs.delete(factoryID);',
|
|
' });',
|
|
' },',
|
|
' };',
|
|
'};',
|
|
...(options.emitNextEvents ? getOpenCode2SetupSource() : []),
|
|
'',
|
|
'// Why: OpenCode also resolves plugins through the module default export, and that',
|
|
'// loader rejects the module unless the default exposes `server()` ("must default',
|
|
'// export an object with server()"). `setup()` does not satisfy it. Keep the named',
|
|
'// export so the factory-based loader still finds the same instance.',
|
|
'export default {',
|
|
` id: "${pluginID}",`,
|
|
' server: OrcaOpenCodeStatusPlugin,',
|
|
...(options.emitNextEvents ? [' setup: setupOpenCode2Status,'] : []),
|
|
'};',
|
|
''
|
|
]
|
|
}
|