Files
orca/src/main/opencode/status-plugin-factory-source.ts
T
Neil 48e63c015f refactor agent config and auth services (#16195)
* refactor: split agent config and auth services

* chore: repoint wsl and global-fetch guards at split module paths

* fix: restore merge-base Claude CLI error propagation

Drop the secret-redaction rewriting added to Claude CLI error paths in the
refactor: spawn errors again reject with the original Error (preserving
.code/.errno/.syscall/.stack) and command output/auth-status logs are no
longer rewritten.
2026-08-24 23:15:01 -07:00

226 lines
10 KiB
TypeScript

export function getStatusPluginFactorySource(options: { emitSessionStart: boolean }): string[] {
return [
'// 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) => {',
' const client = _ctx?.client;',
' const factoryID = ++nextFactoryID;',
' activeFactoryIDs.add(factoryID);',
' let disposed = false;',
' 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.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"',
' ) {',
' await enqueueLifecycle(() =>',
' disposed ? undefined : handleLifecycleEvent(client, 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;',
' 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);',
' });',
' },',
' };',
'};',
'',
'// 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: "orca-opencode-status",',
' server: OrcaOpenCodeStatusPlugin,',
'};',
''
]
}