import { getOpenCode2ModuleSource, 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 sessionsOutliveDispose = _ctx?.sessionsOutliveDispose === true;', ' 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);', ' if (isOpenCodeRunProcess()) return; // a `run` goes Busy at once; its start row only blinks idle', ' 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 `` 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;', ' // Why: OpenCode 1 disposes only on instance teardown, which cancels every run, so a final', ' // Idle is true. OpenCode 2 also disposes on a hot reload mid-turn, so it publishes nothing.', ' if (sessionsOutliveDispose) {', ' if (desiredFactoryID === factoryID) {', ' clearStatusRetry();', ' statusRevision += 1;', ' }', ' } else 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 {', ' 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 ? getOpenCode2ModuleSource(pluginID, expectedAgent) : []), '', '// 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,'] : []), '};', '' ] }