{{ message }}
Repository navigation
Expand file tree
/
Copy pathstatus-plugin-factory-source.ts
More file actions
300 lines (299 loc) · 15.1 KB
/
Copy pathstatus-plugin-factory-source.ts
File metadata and controls
300 lines (299 loc) · 15.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
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 `<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;',
' // 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) : []),
'',
'// OpenCode 1 requires a callable default; OpenCode 2 validates a plugin object.',
'export default process.env.ORCA_OPENCODE_PLUGIN_API === "v1" ? OrcaOpenCodeStatusPlugin : {',
` id: "${pluginID}",`,
' server: OrcaOpenCodeStatusPlugin,',
...(options.emitNextEvents ? [' setup: setupOpenCode2Status,'] : []),
'};',
''
]
}
You can’t perform that action at this time.
