opencode-async-agent 1.0.0 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,630 +0,0 @@
1
- import type { TextPart } from "@opencode-ai/sdk"
2
- import {
3
- MAX_RUN_TIME_MS,
4
- type OpencodeClient,
5
- type Logger,
6
- type Delegation,
7
- type DelegateInput,
8
- type DelegationListItem,
9
- type ReadDelegationArgs,
10
- type SessionMessageItem,
11
- type AssistantSessionMessageItem,
12
- } from "./types"
13
- import { showToast, formatDuration } from "./utils"
14
-
15
- // Same logic as OpenCode's Provider.parseModel() — first "/" splits provider from model
16
- function parseModel(model: string): { providerID: string; modelID: string } {
17
- const [providerID, ...rest] = model.split("/")
18
- return { providerID, modelID: rest.join("/") }
19
- }
20
-
21
- export class DelegationManager {
22
- private delegations: Map<string, Delegation> = new Map()
23
- private client: OpencodeClient
24
- private log: Logger
25
- private pendingByParent: Map<string, Set<string>> = new Map()
26
-
27
- constructor(client: OpencodeClient, log: Logger) {
28
- this.client = client
29
- this.log = log
30
- }
31
-
32
- private calculateDuration(delegation: Delegation): string {
33
- return formatDuration(delegation.startedAt, delegation.completedAt)
34
- }
35
-
36
- // ---- Core operations ----
37
-
38
- async delegate(input: DelegateInput): Promise<Delegation> {
39
- await this.debugLog(`delegate() called`)
40
-
41
- // Validate agent exists
42
- const agentsResult = await this.client.app.agents({})
43
- const agents = (agentsResult.data ?? []) as {
44
- name: string
45
- description?: string
46
- mode?: string
47
- }[]
48
- const validAgent = agents.find((a) => a.name === input.agent)
49
-
50
- if (!validAgent) {
51
- const available = agents
52
- .filter((a) => a.mode === "subagent" || a.mode === "all" || !a.mode)
53
- .map((a) => `• ${a.name}${a.description ? ` - ${a.description}` : ""}`)
54
- .join("\n")
55
-
56
- throw new Error(
57
- `Agent "${input.agent}" not found.\n\nAvailable agents:\n${available || "(none)"}`,
58
- )
59
- }
60
-
61
- // Create isolated session — its ID becomes the delegation ID
62
- const sessionResult = await this.client.session.create({
63
- body: {
64
- title: `Delegation: ${input.agent}`,
65
- parentID: input.parentSessionID,
66
- },
67
- })
68
-
69
- await this.debugLog(`session.create result: ${JSON.stringify(sessionResult.data)}`)
70
-
71
- if (!sessionResult.data?.id) {
72
- throw new Error("Failed to create delegation session")
73
- }
74
-
75
- // Use OpenCode session ID as the delegation ID
76
- const sessionID = sessionResult.data.id
77
-
78
- const delegation: Delegation = {
79
- id: sessionID,
80
- sessionID: sessionID,
81
- parentSessionID: input.parentSessionID,
82
- parentMessageID: input.parentMessageID,
83
- parentAgent: input.parentAgent,
84
- prompt: input.prompt,
85
- agent: input.agent,
86
- model: input.model,
87
- status: "running",
88
- startedAt: new Date(),
89
- progress: {
90
- toolCalls: 0,
91
- lastUpdate: new Date(),
92
- },
93
- }
94
-
95
- await this.debugLog(`Created delegation ${delegation.id}`)
96
- this.delegations.set(delegation.id, delegation)
97
-
98
- // Track for batched notification
99
- const parentId = input.parentSessionID
100
- if (!this.pendingByParent.has(parentId)) {
101
- this.pendingByParent.set(parentId, new Set())
102
- }
103
- this.pendingByParent.get(parentId)?.add(delegation.id)
104
- await this.debugLog(
105
- `Tracking delegation ${delegation.id} for parent ${parentId}. Pending count: ${this.pendingByParent.get(parentId)?.size}`,
106
- )
107
-
108
- // Timeout timer
109
- setTimeout(() => {
110
- const current = this.delegations.get(delegation.id)
111
- if (current && current.status === "running") {
112
- this.handleTimeout(delegation.id)
113
- }
114
- }, MAX_RUN_TIME_MS + 5000)
115
-
116
- // Toast: task launched
117
- showToast(this.client, "New Background Task", `${delegation.id} (${input.agent})`, "info", 3000)
118
-
119
- // Fire the prompt — optionally override model if specified
120
- const promptBody: any = {
121
- agent: input.agent,
122
- parts: [{ type: "text", text: input.prompt }],
123
- tools: {
124
- task: false,
125
- delegate: false,
126
- todowrite: false,
127
- plan_save: false,
128
- },
129
- }
130
- if (input.model) {
131
- promptBody.model = parseModel(input.model)
132
- }
133
-
134
- this.client.session
135
- .prompt({
136
- path: { id: delegation.sessionID },
137
- body: promptBody,
138
- })
139
- .catch((error: Error) => {
140
- delegation.status = "error"
141
- delegation.error = error.message
142
- delegation.completedAt = new Date()
143
- delegation.duration = this.calculateDuration(delegation)
144
- this.notifyParent(delegation)
145
- })
146
-
147
- return delegation
148
- }
149
-
150
- async resume(delegationId: string, newPrompt?: string): Promise<Delegation> {
151
- const delegation = this.delegations.get(delegationId)
152
- if (!delegation) {
153
- throw new Error(`Delegation "${delegationId}" not found`)
154
- }
155
-
156
- if (delegation.status === "running") {
157
- throw new Error(`Delegation is already running. Wait for it to complete or cancel it first.`)
158
- }
159
-
160
- // Reset status
161
- delegation.status = "running"
162
- delegation.completedAt = undefined
163
- delegation.error = undefined
164
- delegation.startedAt = new Date()
165
- delegation.progress = {
166
- toolCalls: 0,
167
- lastUpdate: new Date(),
168
- }
169
-
170
- // Track again
171
- const parentId = delegation.parentSessionID
172
- if (!this.pendingByParent.has(parentId)) {
173
- this.pendingByParent.set(parentId, new Set())
174
- }
175
- this.pendingByParent.get(parentId)?.add(delegation.id)
176
-
177
- // Send continue prompt to same session — reuse model if set
178
- const prompt = newPrompt || "Continue from where you left off."
179
-
180
- const resumeBody: any = {
181
- agent: delegation.agent,
182
- parts: [{ type: "text", text: prompt }],
183
- tools: {
184
- task: false,
185
- delegate: false,
186
- todowrite: false,
187
- plan_save: false,
188
- },
189
- }
190
- if (delegation.model) {
191
- resumeBody.model = parseModel(delegation.model)
192
- }
193
-
194
- this.client.session
195
- .prompt({
196
- path: { id: delegation.sessionID },
197
- body: resumeBody,
198
- })
199
- .catch((error: Error) => {
200
- delegation.status = "error"
201
- delegation.error = error.message
202
- delegation.completedAt = new Date()
203
- delegation.duration = this.calculateDuration(delegation)
204
- this.notifyParent(delegation)
205
- })
206
-
207
- await this.debugLog(`Resumed delegation ${delegation.id}`)
208
- return delegation
209
- }
210
-
211
- async cancel(delegationId: string): Promise<boolean> {
212
- const delegation = this.delegations.get(delegationId)
213
- if (!delegation) return false
214
- if (delegation.status !== "running") return false
215
-
216
- // Set cancelled BEFORE abort to prevent race with session.idle event
217
- // Otherwise handleSessionIdle() sees status="running" and marks it "completed"
218
- delegation.status = "cancelled"
219
- delegation.completedAt = new Date()
220
- delegation.duration = this.calculateDuration(delegation)
221
-
222
- // Abort the session
223
- try {
224
- await this.client.session.abort({
225
- path: { id: delegation.sessionID },
226
- })
227
- } catch {
228
- // Ignore abort errors
229
- }
230
-
231
- // Remove from pending
232
- const pendingSet = this.pendingByParent.get(delegation.parentSessionID)
233
- if (pendingSet) {
234
- pendingSet.delete(delegationId)
235
- }
236
-
237
- await this.notifyParent(delegation)
238
-
239
- // Toast: task cancelled
240
- showToast(this.client, "Task Cancelled", `${delegation.id} cancelled (${delegation.duration})`, "info", 3000)
241
-
242
- await this.debugLog(`Cancelled delegation ${delegation.id}`)
243
- return true
244
- }
245
-
246
- async cancelAll(parentSessionID: string): Promise<string[]> {
247
- const cancelled: string[] = []
248
-
249
- for (const delegation of this.delegations.values()) {
250
- if (delegation.parentSessionID === parentSessionID && delegation.status === "running") {
251
- const success = await this.cancel(delegation.id)
252
- if (success) {
253
- cancelled.push(delegation.id)
254
- }
255
- }
256
- }
257
-
258
- return cancelled
259
- }
260
-
261
- // ---- Event handlers ----
262
-
263
- private async handleTimeout(delegationId: string): Promise<void> {
264
- const delegation = this.delegations.get(delegationId)
265
- if (!delegation || delegation.status !== "running") return
266
-
267
- await this.debugLog(`handleTimeout for delegation ${delegation.id}`)
268
-
269
- delegation.status = "timeout"
270
- delegation.completedAt = new Date()
271
- delegation.duration = this.calculateDuration(delegation)
272
- delegation.error = `Delegation timed out after ${MAX_RUN_TIME_MS / 1000}s`
273
-
274
- try {
275
- await this.client.session.abort({
276
- path: { id: delegation.sessionID },
277
- })
278
- } catch {
279
- // Ignore
280
- }
281
-
282
- await this.notifyParent(delegation)
283
- }
284
-
285
- async handleSessionIdle(sessionID: string): Promise<void> {
286
- const delegation = this.findBySession(sessionID)
287
- if (!delegation || delegation.status !== "running") return
288
-
289
- await this.debugLog(`handleSessionIdle for delegation ${delegation.id}`)
290
-
291
- delegation.status = "completed"
292
- delegation.completedAt = new Date()
293
- delegation.duration = this.calculateDuration(delegation)
294
-
295
- // Extract title/description from first user message
296
- try {
297
- const messages = await this.client.session.messages({
298
- path: { id: delegation.sessionID },
299
- })
300
- const messageData = messages.data as SessionMessageItem[] | undefined
301
- if (messageData && messageData.length > 0) {
302
- const firstUser = messageData.find(m => m.info.role === "user")
303
- if (firstUser) {
304
- const textPart = firstUser.parts.find((p): p is TextPart => p.type === "text")
305
- if (textPart) {
306
- delegation.description = textPart.text.slice(0, 150)
307
- delegation.title = textPart.text.split('\n')[0].slice(0, 50)
308
- }
309
- }
310
- }
311
- } catch {
312
- // Ignore
313
- }
314
-
315
- showToast(
316
- this.client,
317
- "Task Completed",
318
- `"${delegation.id}" finished in ${delegation.duration}`,
319
- "success",
320
- 5000,
321
- )
322
-
323
- await this.notifyParent(delegation)
324
- }
325
-
326
- // ---- Read delegation results ----
327
-
328
- async readDelegation(args: ReadDelegationArgs): Promise<string> {
329
- const delegation = this.delegations.get(args.id)
330
- if (!delegation) {
331
- throw new Error(`Delegation "${args.id}" not found.\n\nUse delegation_list() to see available delegations.`)
332
- }
333
-
334
- if (delegation.status === "running") {
335
- return `Delegation "${args.id}" is still running.\n\nStatus: ${delegation.status}\nStarted: ${delegation.startedAt.toISOString()}\n\nWait for completion notification, then call delegation_read() again.`
336
- }
337
-
338
- if (delegation.status !== "completed") {
339
- let statusMessage = `Delegation "${args.id}" ended with status: ${delegation.status}`
340
- if (delegation.error) statusMessage += `\n\nError: ${delegation.error}`
341
- if (delegation.duration) statusMessage += `\n\nDuration: ${delegation.duration}`
342
- return statusMessage
343
- }
344
-
345
- if (!args.mode || args.mode === "simple") {
346
- return await this.getSimpleResult(delegation)
347
- }
348
-
349
- if (args.mode === "full") {
350
- return await this.getFullSession(delegation, args)
351
- }
352
-
353
- return "Invalid mode. Use 'simple' or 'full'."
354
- }
355
-
356
- private async getSimpleResult(delegation: Delegation): Promise<string> {
357
- try {
358
- const messages = await this.client.session.messages({
359
- path: { id: delegation.sessionID },
360
- })
361
-
362
- const messageData = messages.data as SessionMessageItem[] | undefined
363
-
364
- if (!messageData || messageData.length === 0) {
365
- return `Delegation "${delegation.id}" completed but produced no output.`
366
- }
367
-
368
- const assistantMessages = messageData.filter(
369
- (m): m is AssistantSessionMessageItem => m.info.role === "assistant"
370
- )
371
-
372
- if (assistantMessages.length === 0) {
373
- return `Delegation "${delegation.id}" completed but produced no assistant response.`
374
- }
375
-
376
- const lastMessage = assistantMessages[assistantMessages.length - 1]
377
- const textParts = lastMessage.parts.filter((p): p is TextPart => p.type === "text")
378
-
379
- if (textParts.length === 0) {
380
- return `Delegation "${delegation.id}" completed but produced no text content.`
381
- }
382
-
383
- const result = textParts.map((p) => p.text).join("\n")
384
-
385
- const header = `# Task Result: ${delegation.id}
386
-
387
- **Agent:** ${delegation.agent}
388
- **Status:** ${delegation.status}
389
- **Duration:** ${delegation.duration || "N/A"}
390
- **Started:** ${delegation.startedAt.toISOString()}
391
- ${delegation.completedAt ? `**Completed:** ${delegation.completedAt.toISOString()}` : ""}
392
-
393
- ---
394
-
395
- `
396
-
397
- return header + result
398
- } catch (error) {
399
- return `Error retrieving result: ${error instanceof Error ? error.message : "Unknown error"}`
400
- }
401
- }
402
-
403
- private async getFullSession(delegation: Delegation, args: ReadDelegationArgs): Promise<string> {
404
- try {
405
- const messages = await this.client.session.messages({
406
- path: { id: delegation.sessionID },
407
- })
408
-
409
- const messageData = messages.data as SessionMessageItem[] | undefined
410
-
411
- if (!messageData || messageData.length === 0) {
412
- return `Delegation "${delegation.id}" has no messages.`
413
- }
414
-
415
- const sortedMessages = [...messageData].sort((a, b) => {
416
- const timeA = String(a.info.time || "")
417
- const timeB = String(b.info.time || "")
418
- return timeA.localeCompare(timeB)
419
- })
420
-
421
- let filteredMessages = sortedMessages
422
- if (args.since_message_id) {
423
- const index = sortedMessages.findIndex((m) => m.info.id === args.since_message_id)
424
- if (index !== -1) {
425
- filteredMessages = sortedMessages.slice(index + 1)
426
- }
427
- }
428
-
429
- const limit = args.limit ? Math.min(args.limit, 100) : undefined
430
- const hasMore = limit !== undefined && filteredMessages.length > limit
431
- const visibleMessages = limit !== undefined ? filteredMessages.slice(0, limit) : filteredMessages
432
-
433
- const lines: string[] = []
434
- lines.push(`# Full Session: ${delegation.id}`)
435
- lines.push("")
436
- lines.push(`**Agent:** ${delegation.agent}`)
437
- lines.push(`**Status:** ${delegation.status}`)
438
- lines.push(`**Duration:** ${delegation.duration || "N/A"}`)
439
- lines.push(`**Total messages:** ${sortedMessages.length}`)
440
- lines.push(`**Returned:** ${visibleMessages.length}`)
441
- lines.push(`**Has more:** ${hasMore ? "true" : "false"}`)
442
- lines.push("")
443
- lines.push("## Messages")
444
- lines.push("")
445
-
446
- for (const message of visibleMessages) {
447
- const role = message.info.role
448
- const time = message.info.time || "unknown"
449
- const id = message.info.id || "unknown"
450
-
451
- lines.push(`### [${role}] ${time} (id: ${id})`)
452
- lines.push("")
453
-
454
- for (const part of message.parts) {
455
- if (part.type === "text" && part.text) {
456
- lines.push(part.text.trim())
457
- lines.push("")
458
- }
459
-
460
- if (args.include_thinking && (part.type === "thinking" || part.type === "reasoning")) {
461
- const thinkingText = (part as any).thinking || (part as any).text || ""
462
- if (thinkingText) {
463
- lines.push(`[thinking] ${thinkingText.slice(0, 2000)}`)
464
- lines.push("")
465
- }
466
- }
467
-
468
- if (args.include_tools && part.type === "tool_result") {
469
- const content = (part as any).content || (part as any).output || ""
470
- if (content) {
471
- lines.push(`[tool result] ${content}`)
472
- lines.push("")
473
- }
474
- }
475
- }
476
- }
477
-
478
- return lines.join("\n")
479
- } catch (error) {
480
- return `Error fetching session: ${error instanceof Error ? error.message : "Unknown error"}`
481
- }
482
- }
483
-
484
- // ---- Notification ----
485
-
486
- private async notifyParent(delegation: Delegation): Promise<void> {
487
- try {
488
- const pendingSet = this.pendingByParent.get(delegation.parentSessionID)
489
- if (pendingSet) {
490
- pendingSet.delete(delegation.id)
491
- }
492
-
493
- const allComplete = !pendingSet || pendingSet.size === 0
494
- const remainingCount = pendingSet?.size || 0
495
-
496
- const statusText = delegation.status === "completed" ? "COMPLETED"
497
- : delegation.status === "cancelled" ? "CANCELLED"
498
- : delegation.status === "error" ? "ERROR"
499
- : delegation.status === "timeout" ? "TIMEOUT"
500
- : delegation.status.toUpperCase()
501
- const duration = delegation.duration || "N/A"
502
- const errorInfo = delegation.error ? `\n**Error:** ${delegation.error}` : ""
503
-
504
- let notification: string
505
-
506
- if (allComplete) {
507
- const completedTasks: Delegation[] = []
508
- for (const d of this.delegations.values()) {
509
- if (d.parentSessionID === delegation.parentSessionID && d.status !== "running") {
510
- completedTasks.push(d)
511
- }
512
- }
513
- const completedList = completedTasks
514
- .map(t => `- \`${t.id}\`: ${t.title || t.prompt.slice(0, 80)}`)
515
- .join("\n")
516
-
517
- const sessionHints = completedTasks
518
- .map(t => `opencode -s ${t.id}`)
519
- .join("\n")
520
-
521
- notification = `<system-reminder>
522
- [ALL BACKGROUND TASKS COMPLETE]
523
-
524
- **Completed:**
525
- ${completedList || `- \`${delegation.id}\`: ${delegation.title || delegation.prompt.slice(0, 80)}`}
526
-
527
- Use \`delegation_read(id="<id>")\` to retrieve each result.
528
- </system-reminder>
529
- To inspect session content(human): ${sessionHints || `opencode -s ${delegation.id}`}`
530
- } else {
531
- notification = `<system-reminder>
532
- [BACKGROUND TASK ${statusText}]
533
- **ID:** \`${delegation.id}\`
534
- **Agent:** ${delegation.agent}
535
- **Duration:** ${duration}${errorInfo}
536
-
537
- **${remainingCount} task${remainingCount === 1 ? "" : "s"} still in progress.** You WILL be notified when ALL complete.
538
- Do NOT poll - continue productive work.
539
-
540
- Use \`delegation_read(id="${delegation.id}")\` to retrieve this result when ready.
541
- </system-reminder>
542
- To inspect session content(human): opencode -s ${delegation.id}`
543
- }
544
-
545
- // Fire and forget - don't await to avoid deadlock when parent session is busy
546
- this.client.session.prompt({
547
- path: { id: delegation.parentSessionID },
548
- body: {
549
- noReply: !allComplete,
550
- agent: delegation.parentAgent,
551
- parts: [{ type: "text", text: notification }],
552
- },
553
- }).catch(() => {})
554
-
555
- await this.debugLog(
556
- `Notified parent session ${delegation.parentSessionID} (status=${statusText}, remaining=${remainingCount})`,
557
- )
558
- } catch (error) {
559
- await this.debugLog(
560
- `Failed to notify parent: ${error instanceof Error ? error.message : "Unknown error"}`,
561
- )
562
- }
563
- }
564
-
565
- // ---- Queries ----
566
-
567
- async listDelegations(parentSessionID: string): Promise<DelegationListItem[]> {
568
- const results: DelegationListItem[] = []
569
-
570
- for (const delegation of this.delegations.values()) {
571
- if (delegation.parentSessionID === parentSessionID) {
572
- results.push({
573
- id: delegation.id,
574
- status: delegation.status,
575
- title: delegation.title,
576
- description: delegation.description,
577
- agent: delegation.agent,
578
- duration: delegation.duration,
579
- startedAt: delegation.startedAt,
580
- })
581
- }
582
- }
583
-
584
- return results.sort((a, b) => (b.startedAt?.getTime() || 0) - (a.startedAt?.getTime() || 0))
585
- }
586
-
587
- listAllDelegations(): DelegationListItem[] {
588
- const results: DelegationListItem[] = []
589
- for (const delegation of this.delegations.values()) {
590
- results.push({
591
- id: delegation.id,
592
- status: delegation.status,
593
- title: delegation.title,
594
- description: delegation.description,
595
- agent: delegation.agent,
596
- duration: delegation.duration,
597
- startedAt: delegation.startedAt,
598
- })
599
- }
600
- return results.sort((a, b) => (b.startedAt?.getTime() || 0) - (a.startedAt?.getTime() || 0))
601
- }
602
-
603
- findBySession(sessionID: string): Delegation | undefined {
604
- return Array.from(this.delegations.values()).find((d) => d.sessionID === sessionID)
605
- }
606
-
607
- handleMessageEvent(sessionID: string, messageText?: string): void {
608
- const delegation = this.findBySession(sessionID)
609
- if (!delegation || delegation.status !== "running") return
610
-
611
- delegation.progress.lastUpdate = new Date()
612
- if (messageText) {
613
- delegation.progress.lastMessage = messageText
614
- delegation.progress.lastMessageAt = new Date()
615
- }
616
- }
617
-
618
- getPendingCount(parentSessionID: string): number {
619
- const pendingSet = this.pendingByParent.get(parentSessionID)
620
- return pendingSet ? pendingSet.size : 0
621
- }
622
-
623
- getRunningDelegations(): Delegation[] {
624
- return Array.from(this.delegations.values()).filter((d) => d.status === "running")
625
- }
626
-
627
- async debugLog(msg: string): Promise<void> {
628
- this.log.debug(msg)
629
- }
630
- }