@agentuity/opencode 1.0.19 → 1.0.21

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.
Files changed (98) hide show
  1. package/dist/agents/expert-backend.d.ts +1 -1
  2. package/dist/agents/expert-backend.d.ts.map +1 -1
  3. package/dist/agents/expert-backend.js +0 -17
  4. package/dist/agents/expert-backend.js.map +1 -1
  5. package/dist/agents/expert.d.ts +1 -1
  6. package/dist/agents/expert.d.ts.map +1 -1
  7. package/dist/agents/expert.js +1 -1
  8. package/dist/agents/index.d.ts.map +1 -1
  9. package/dist/agents/index.js +0 -2
  10. package/dist/agents/index.js.map +1 -1
  11. package/dist/agents/lead.d.ts +1 -1
  12. package/dist/agents/lead.d.ts.map +1 -1
  13. package/dist/agents/lead.js +25 -145
  14. package/dist/agents/lead.js.map +1 -1
  15. package/dist/agents/scout.d.ts +1 -1
  16. package/dist/agents/scout.d.ts.map +1 -1
  17. package/dist/agents/scout.js +16 -0
  18. package/dist/agents/scout.js.map +1 -1
  19. package/dist/config/loader.d.ts.map +1 -1
  20. package/dist/config/loader.js +1 -33
  21. package/dist/config/loader.js.map +1 -1
  22. package/dist/index.d.ts +1 -1
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js.map +1 -1
  25. package/dist/plugin/hooks/cadence.d.ts +1 -2
  26. package/dist/plugin/hooks/cadence.d.ts.map +1 -1
  27. package/dist/plugin/hooks/cadence.js +7 -33
  28. package/dist/plugin/hooks/cadence.js.map +1 -1
  29. package/dist/plugin/hooks/compaction-utils.d.ts.map +1 -1
  30. package/dist/plugin/hooks/compaction-utils.js +6 -13
  31. package/dist/plugin/hooks/compaction-utils.js.map +1 -1
  32. package/dist/plugin/hooks/session-memory.d.ts +1 -2
  33. package/dist/plugin/hooks/session-memory.d.ts.map +1 -1
  34. package/dist/plugin/hooks/session-memory.js +6 -29
  35. package/dist/plugin/hooks/session-memory.js.map +1 -1
  36. package/dist/plugin/plugin.d.ts.map +1 -1
  37. package/dist/plugin/plugin.js +8 -222
  38. package/dist/plugin/plugin.js.map +1 -1
  39. package/dist/sqlite/types.d.ts +0 -6
  40. package/dist/sqlite/types.d.ts.map +1 -1
  41. package/dist/tmux/manager.d.ts +4 -4
  42. package/dist/tmux/manager.js +4 -4
  43. package/dist/tmux/types.d.ts +1 -1
  44. package/dist/tools/index.d.ts +1 -1
  45. package/dist/tools/index.d.ts.map +1 -1
  46. package/dist/tools/index.js +1 -1
  47. package/dist/tools/index.js.map +1 -1
  48. package/dist/types.d.ts +2 -20
  49. package/dist/types.d.ts.map +1 -1
  50. package/dist/types.js +0 -9
  51. package/dist/types.js.map +1 -1
  52. package/package.json +3 -3
  53. package/src/agents/expert-backend.ts +0 -17
  54. package/src/agents/expert.ts +1 -1
  55. package/src/agents/index.ts +0 -2
  56. package/src/agents/lead.ts +25 -145
  57. package/src/agents/scout.ts +16 -0
  58. package/src/config/loader.ts +1 -45
  59. package/src/index.ts +0 -1
  60. package/src/plugin/hooks/cadence.ts +6 -39
  61. package/src/plugin/hooks/compaction-utils.ts +6 -12
  62. package/src/plugin/hooks/session-memory.ts +5 -35
  63. package/src/plugin/plugin.ts +7 -257
  64. package/src/sqlite/types.ts +0 -2
  65. package/src/tmux/manager.ts +4 -4
  66. package/src/tmux/types.ts +2 -2
  67. package/src/tools/index.ts +2 -9
  68. package/src/types.ts +0 -13
  69. package/dist/agents/monitor.d.ts +0 -4
  70. package/dist/agents/monitor.d.ts.map +0 -1
  71. package/dist/agents/monitor.js +0 -159
  72. package/dist/agents/monitor.js.map +0 -1
  73. package/dist/background/concurrency.d.ts +0 -36
  74. package/dist/background/concurrency.d.ts.map +0 -1
  75. package/dist/background/concurrency.js +0 -92
  76. package/dist/background/concurrency.js.map +0 -1
  77. package/dist/background/index.d.ts +0 -5
  78. package/dist/background/index.d.ts.map +0 -1
  79. package/dist/background/index.js +0 -4
  80. package/dist/background/index.js.map +0 -1
  81. package/dist/background/manager.d.ts +0 -123
  82. package/dist/background/manager.d.ts.map +0 -1
  83. package/dist/background/manager.js +0 -1075
  84. package/dist/background/manager.js.map +0 -1
  85. package/dist/background/types.d.ts +0 -90
  86. package/dist/background/types.d.ts.map +0 -1
  87. package/dist/background/types.js +0 -2
  88. package/dist/background/types.js.map +0 -1
  89. package/dist/tools/background.d.ts +0 -67
  90. package/dist/tools/background.d.ts.map +0 -1
  91. package/dist/tools/background.js +0 -95
  92. package/dist/tools/background.js.map +0 -1
  93. package/src/agents/monitor.ts +0 -161
  94. package/src/background/concurrency.ts +0 -116
  95. package/src/background/index.ts +0 -4
  96. package/src/background/manager.ts +0 -1215
  97. package/src/background/types.ts +0 -82
  98. package/src/tools/background.ts +0 -179
@@ -1,1215 +0,0 @@
1
- import type { PluginInput } from '@opencode-ai/plugin';
2
- import { agents } from '../agents';
3
- import type { AgentDefinition } from '../agents';
4
- import type { DBTextPart, OpenCodeDBReader } from '../sqlite';
5
- import type {
6
- BackgroundTask,
7
- BackgroundTaskConfig,
8
- BackgroundTaskStatus,
9
- LaunchInput,
10
- TaskInspection,
11
- TaskProgress,
12
- } from './types';
13
- import { ConcurrencyManager } from './concurrency';
14
-
15
- const DEFAULT_BACKGROUND_CONFIG: BackgroundTaskConfig = {
16
- enabled: true,
17
- defaultConcurrency: 5,
18
- staleTimeoutMs: 30 * 60 * 1000,
19
- };
20
-
21
- type MessagePart = {
22
- type?: string;
23
- text?: string;
24
- tool?: string;
25
- callID?: string;
26
- state?: { status?: string };
27
- sessionID?: string;
28
- };
29
-
30
- type EventPayload = {
31
- type: string;
32
- properties?: Record<string, unknown>;
33
- };
34
-
35
- export interface BackgroundManagerCallbacks {
36
- onSubagentSessionCreated?: (event: {
37
- sessionId: string;
38
- parentId: string;
39
- title: string;
40
- }) => void;
41
- onSubagentSessionDeleted?: (event: { sessionId: string }) => void;
42
- onShutdown?: () => void | Promise<void>;
43
- }
44
-
45
- export class BackgroundManager {
46
- private ctx: PluginInput;
47
- private config: BackgroundTaskConfig;
48
- private concurrency: ConcurrencyManager;
49
- private callbacks?: BackgroundManagerCallbacks;
50
- private dbReader?: OpenCodeDBReader;
51
- private serverUrl: string | undefined;
52
- private authHeaders: Record<string, string> | undefined;
53
- private tasks = new Map<string, BackgroundTask>();
54
- private tasksByParent = new Map<string, Set<string>>();
55
- private tasksBySession = new Map<string, string>();
56
- private notifications = new Map<string, Set<string>>();
57
- private toolCallIds = new Map<string, Set<string>>();
58
- /** Tracks tool call IDs that are currently in-flight (pending/running state) per task */
59
- private activeToolCallIds = new Map<string, Set<string>>();
60
- /** Maps parent session ID → monitor task ID for auto-launched monitors */
61
- private monitorsPerParent = new Map<string, string>();
62
- private lastNotifyTimes = new Map<string, number>();
63
- private shuttingDown = false;
64
- private refreshIntervalId: ReturnType<typeof setInterval> | undefined;
65
-
66
- constructor(
67
- ctx: PluginInput,
68
- config?: BackgroundTaskConfig,
69
- callbacks?: BackgroundManagerCallbacks,
70
- dbReader?: OpenCodeDBReader
71
- ) {
72
- this.ctx = ctx;
73
- this.config = { ...DEFAULT_BACKGROUND_CONFIG, ...config };
74
- this.concurrency = new ConcurrencyManager({
75
- defaultLimit: this.config.defaultConcurrency,
76
- limits: buildConcurrencyLimits(this.config),
77
- });
78
- this.callbacks = callbacks;
79
- this.dbReader = dbReader;
80
- this.serverUrl = this.resolveServerUrl();
81
- this.authHeaders = this.resolveAuthHeaders();
82
-
83
- // Periodic safety net: refresh task statuses every 30s in case events are missed
84
- this.refreshIntervalId = setInterval(() => {
85
- if (this.shuttingDown) return;
86
- const hasActive = Array.from(this.tasks.values()).some(
87
- (t) => t.status === 'pending' || t.status === 'running'
88
- );
89
- if (hasActive) {
90
- void this.refreshStatuses();
91
- }
92
- }, 30_000);
93
- }
94
-
95
- /**
96
- * Resolve the server URL from the plugin context.
97
- * Mirrors the defensive pattern used in the tmux manager to handle
98
- * sandbox environments where the client may not have a baseUrl configured.
99
- */
100
- private resolveServerUrl(): string | undefined {
101
- const ctx = this.ctx as unknown as {
102
- serverUrl?: string | URL;
103
- baseUrl?: string | URL;
104
- client?: { baseUrl?: string | URL };
105
- };
106
- const serverUrl = ctx.serverUrl ?? ctx.baseUrl ?? ctx.client?.baseUrl;
107
- if (!serverUrl) return undefined;
108
- const urlStr = typeof serverUrl === 'string' ? serverUrl : serverUrl.toString();
109
- // Strip trailing slash to prevent double-slash when SDK appends paths like /session
110
- return urlStr.replace(/\/+$/, '');
111
- }
112
-
113
- /**
114
- * Resolve authentication headers from environment variables.
115
- *
116
- * Reads `OPENCODE_SERVER_USERNAME` and `OPENCODE_SERVER_PASSWORD` (set
117
- * automatically by the OpenCode server in sandbox environments) and
118
- * produces a Basic Auth header (`base64("username:password")`).
119
- *
120
- * In sandbox environments the SDK client's default auth may not carry over
121
- * when a per-call `baseUrl` override is provided, so we need to explicitly
122
- * attach these credentials for server-to-server requests.
123
- */
124
- private resolveAuthHeaders(): Record<string, string> | undefined {
125
- const username = process.env.OPENCODE_SERVER_USERNAME;
126
- const password = process.env.OPENCODE_SERVER_PASSWORD;
127
- if (!username || !password) return undefined;
128
- const encoded = Buffer.from(username + ':' + password).toString('base64');
129
- return { Authorization: `Basic ${encoded}` };
130
- }
131
-
132
- /**
133
- * Build the per-call client overrides (baseUrl + auth headers).
134
- * Spread this into every SDK client call so both the server URL and
135
- * authentication are correctly forwarded in sandbox environments.
136
- */
137
- private getClientOverrides(): { baseUrl?: string; headers?: Record<string, string> } {
138
- const overrides: { baseUrl?: string; headers?: Record<string, string> } = {};
139
- if (this.serverUrl) overrides.baseUrl = this.serverUrl;
140
- if (this.authHeaders) overrides.headers = this.authHeaders;
141
- return overrides;
142
- }
143
-
144
- async launch(input: LaunchInput): Promise<BackgroundTask> {
145
- const task: BackgroundTask = {
146
- id: createTaskId(),
147
- parentSessionId: input.parentSessionId,
148
- parentMessageId: input.parentMessageId,
149
- description: input.description,
150
- prompt: input.prompt,
151
- agent: input.agent,
152
- status: 'pending',
153
- queuedAt: new Date(),
154
- concurrencyGroup: this.getConcurrencyGroup(input.agent),
155
- notifiedStatuses: new Set(),
156
- };
157
-
158
- this.tasks.set(task.id, task);
159
- this.indexTask(task);
160
-
161
- if (!this.config.enabled) {
162
- task.status = 'error';
163
- task.error = 'Background tasks are disabled.';
164
- task.completedAt = new Date();
165
- this.markForNotification(task);
166
- return task;
167
- }
168
-
169
- void this.startTask(task);
170
-
171
- // Auto-launch a Monitor for this parent session if not already running.
172
- // Monitor uses session_dashboard scoped to the parent session ID, so it only
173
- // sees sibling tasks — not unrelated sessions across the server.
174
- void this.ensureMonitorForParent(input.parentSessionId);
175
-
176
- return task;
177
- }
178
-
179
- getTask(id: string): BackgroundTask | undefined {
180
- return this.tasks.get(id);
181
- }
182
-
183
- getTasksByParent(sessionId: string): BackgroundTask[] {
184
- const ids = this.tasksByParent.get(sessionId);
185
- if (!ids) return [];
186
- return Array.from(ids)
187
- .map((id) => this.tasks.get(id))
188
- .filter((task): task is BackgroundTask => Boolean(task));
189
- }
190
-
191
- findBySession(sessionId: string): BackgroundTask | undefined {
192
- const taskId = this.tasksBySession.get(sessionId);
193
- if (!taskId) return undefined;
194
- return this.tasks.get(taskId);
195
- }
196
-
197
- /**
198
- * Inspect a background task by fetching its session messages.
199
- * Useful for seeing what a child Lead or other agent is doing.
200
- */
201
- async inspectTask(taskId: string): Promise<TaskInspection | undefined> {
202
- const task = this.tasks.get(taskId);
203
- if (!task) return undefined;
204
-
205
- // Task exists but has not yet acquired a concurrency slot — it is queued
206
- // and no session has been created yet. Return a lightweight inspection so
207
- // callers can distinguish "queued/pending" from "not found".
208
- if (!task.sessionId) {
209
- return {
210
- taskId: task.id,
211
- sessionId: '',
212
- status: task.status,
213
- session: null,
214
- messages: [],
215
- lastActivity: task.queuedAt?.toISOString(),
216
- };
217
- }
218
-
219
- try {
220
- if (this.dbReader?.isAvailable()) {
221
- const session = this.dbReader.getSession(task.sessionId);
222
- const messageCount = this.dbReader.getMessageCount(task.sessionId);
223
- const messageLimit = messageCount > 0 ? messageCount : 100;
224
- const messageRows = this.dbReader.getMessages(task.sessionId, {
225
- limit: messageLimit,
226
- offset: 0,
227
- });
228
- const textParts = this.dbReader.getTextParts(task.sessionId, {
229
- limit: Math.max(messageLimit * 5, 200),
230
- });
231
- const partsByMessage = groupTextPartsByMessage(textParts);
232
- const messages = messageRows
233
- .sort((a, b) => a.timeCreated - b.timeCreated)
234
- .map((message) => ({
235
- info: {
236
- role: message.role,
237
- agent: message.agent,
238
- model: message.model,
239
- cost: message.cost,
240
- tokens: message.tokens,
241
- error: message.error,
242
- timeCreated: message.timeCreated,
243
- timeUpdated: message.timeUpdated,
244
- },
245
- parts: buildTextParts(partsByMessage.get(message.id)),
246
- }));
247
- const activeTools = this.dbReader.getActiveToolCalls(task.sessionId).map((tool) => ({
248
- tool: tool.tool,
249
- status: tool.status,
250
- callId: tool.callId,
251
- }));
252
- const todos = this.dbReader.getTodos(task.sessionId).map((todo) => ({
253
- content: todo.content,
254
- status: todo.status,
255
- priority: todo.priority,
256
- }));
257
- const cost = this.dbReader.getSessionCost(task.sessionId);
258
- const childSessionCount = this.dbReader.getChildSessions(task.sessionId).length;
259
-
260
- return {
261
- taskId: task.id,
262
- sessionId: task.sessionId,
263
- status: task.status,
264
- session,
265
- messages,
266
- lastActivity: task.progress?.lastUpdate?.toISOString(),
267
- messageCount,
268
- activeTools,
269
- todos,
270
- costSummary: {
271
- totalCost: cost.totalCost,
272
- totalTokens: cost.totalTokens,
273
- },
274
- childSessionCount,
275
- };
276
- }
277
-
278
- // Get session details
279
- const sessionResponse = await this.ctx.client.session.get({
280
- path: { id: task.sessionId },
281
- throwOnError: false,
282
- ...this.getClientOverrides(),
283
- });
284
-
285
- // Get messages from the session
286
- const messagesResponse = await this.ctx.client.session.messages({
287
- path: { id: task.sessionId },
288
- throwOnError: false,
289
- ...this.getClientOverrides(),
290
- });
291
-
292
- const session = unwrapResponse<unknown>(sessionResponse);
293
- const rawMessages =
294
- unwrapResponse<Array<{ info: unknown; parts: unknown[] }>>(messagesResponse);
295
- // Defensive array coercion (response may be non-array when throwOnError is false)
296
- const messages = Array.isArray(rawMessages) ? rawMessages : [];
297
-
298
- // Return structured inspection result
299
- return {
300
- taskId: task.id,
301
- sessionId: task.sessionId,
302
- status: task.status,
303
- session,
304
- messages,
305
- lastActivity: task.progress?.lastUpdate?.toISOString(),
306
- };
307
- } catch {
308
- // Session might not exist anymore
309
- return undefined;
310
- }
311
- }
312
-
313
- /**
314
- * Refresh task statuses from the server.
315
- * Useful for recovering state after issues or checking on stuck tasks.
316
- */
317
- async refreshStatuses(): Promise<Map<string, BackgroundTaskStatus>> {
318
- const results = new Map<string, BackgroundTaskStatus>();
319
-
320
- // Get all our tracked session IDs
321
- const sessionIds = Array.from(this.tasksBySession.keys());
322
- if (sessionIds.length === 0) return results;
323
-
324
- try {
325
- // Fetch children for each unique parent (more efficient than individual gets)
326
- const parentIds = new Set<string>();
327
- for (const task of this.tasks.values()) {
328
- if (task.parentSessionId) {
329
- parentIds.add(task.parentSessionId);
330
- }
331
- }
332
-
333
- const completionPromises: Promise<void>[] = [];
334
-
335
- for (const parentId of parentIds) {
336
- const childrenResponse = await this.ctx.client.session.children({
337
- path: { id: parentId },
338
- throwOnError: false,
339
- ...this.getClientOverrides(),
340
- });
341
-
342
- const rawChildren = unwrapResponse<Array<unknown>>(childrenResponse);
343
- const children = Array.isArray(rawChildren) ? rawChildren : [];
344
- for (const child of children) {
345
- const childSession = child as { id?: string; status?: { type?: string } };
346
- if (!childSession.id) continue;
347
-
348
- const matchedTaskId = this.tasksBySession.get(childSession.id);
349
- if (matchedTaskId) {
350
- const task = this.tasks.get(matchedTaskId);
351
- if (task) {
352
- // Terminal tasks are final — never overwrite their status.
353
- // The API can return undefined/unknown status for cleaned-up sessions
354
- // which maps to 'pending' by default; without this guard that would
355
- // illegally resurrect a completed/errored/cancelled task.
356
- if (
357
- task.status === 'completed' ||
358
- task.status === 'error' ||
359
- task.status === 'cancelled'
360
- ) {
361
- continue;
362
- }
363
- const newStatus = this.mapSessionStatusToTaskStatus(childSession);
364
- if (newStatus !== task.status) {
365
- // Use proper handlers to trigger side effects (concurrency, notifications, etc.)
366
- if (newStatus === 'completed' && task.status === 'running') {
367
- completionPromises.push(this.completeTask(task));
368
- results.set(matchedTaskId, newStatus);
369
- } else if (newStatus === 'error') {
370
- this.failTask(task, 'Session ended with error');
371
- results.set(matchedTaskId, newStatus);
372
- } else {
373
- // For other transitions (e.g., pending -> running), direct update is fine
374
- task.status = newStatus;
375
- results.set(matchedTaskId, newStatus);
376
- }
377
- }
378
- }
379
- }
380
- }
381
- }
382
-
383
- // Wait for all completion handlers to finish
384
- await Promise.all(completionPromises);
385
- } catch (error) {
386
- // Log but don't fail - this is a best-effort refresh
387
- console.error('Failed to refresh task statuses:', error);
388
- }
389
-
390
- return results;
391
- }
392
-
393
- /**
394
- * Recover background tasks from existing sessions.
395
- * Call this on plugin startup to restore state after restart.
396
- *
397
- * This method queries all sessions and reconstructs task state from
398
- * sessions that have JSON-encoded task metadata in their title.
399
- *
400
- * @returns The number of tasks recovered
401
- */
402
- async recoverTasks(): Promise<number> {
403
- let recovered = 0;
404
-
405
- try {
406
- if (this.dbReader?.isAvailable()) {
407
- const parentSessionId = process.env.AGENTUITY_OPENCODE_SESSION;
408
- if (parentSessionId) {
409
- const sessions = this.dbReader.getChildSessions(parentSessionId);
410
- for (const sess of sessions) {
411
- if (!sess.title?.startsWith('{')) continue;
412
-
413
- try {
414
- const metadata = JSON.parse(sess.title) as {
415
- taskId?: string;
416
- agent?: string;
417
- description?: string;
418
- createdAt?: string;
419
- };
420
-
421
- if (!metadata.taskId || !metadata.taskId.startsWith('bg_')) continue;
422
- if (this.tasks.has(metadata.taskId)) continue;
423
-
424
- const agentName = metadata.agent ?? 'unknown';
425
- const task: BackgroundTask = {
426
- id: metadata.taskId,
427
- sessionId: sess.id,
428
- parentSessionId: sess.parentId ?? '',
429
- agent: agentName,
430
- description: metadata.description ?? '',
431
- prompt: '',
432
- status: this.mapDbStatusToTaskStatus(sess.id),
433
- queuedAt: metadata.createdAt ? new Date(metadata.createdAt) : new Date(),
434
- startedAt: metadata.createdAt ? new Date(metadata.createdAt) : new Date(),
435
- concurrencyGroup: this.getConcurrencyGroup(agentName),
436
- progress: {
437
- toolCalls: 0,
438
- lastUpdate: new Date(),
439
- activeToolCallsInFlight: 0,
440
- },
441
- };
442
-
443
- // Mark recovered terminal tasks as already notified
444
- if (
445
- task.status === 'completed' ||
446
- task.status === 'error' ||
447
- task.status === 'cancelled'
448
- ) {
449
- task.notifiedStatuses = new Set([task.status]);
450
- }
451
-
452
- this.tasks.set(task.id, task);
453
- this.tasksBySession.set(sess.id, task.id);
454
-
455
- if (task.parentSessionId) {
456
- const parentTasks =
457
- this.tasksByParent.get(task.parentSessionId) ?? new Set();
458
- parentTasks.add(task.id);
459
- this.tasksByParent.set(task.parentSessionId, parentTasks);
460
- }
461
-
462
- recovered++;
463
- } catch {
464
- continue;
465
- }
466
- }
467
- return recovered;
468
- }
469
- }
470
-
471
- // Get all sessions
472
- const sessionsResponse = await this.ctx.client.session.list({
473
- throwOnError: false,
474
- ...this.getClientOverrides(),
475
- });
476
-
477
- const rawSessions = unwrapResponse<Array<unknown>>(sessionsResponse);
478
- const sessions = Array.isArray(rawSessions) ? rawSessions : [];
479
-
480
- for (const session of sessions) {
481
- const sess = session as {
482
- id?: string;
483
- title?: string;
484
- parentID?: string;
485
- status?: { type?: string };
486
- };
487
-
488
- // Check if this is one of our background task sessions
489
- // Our sessions have JSON-encoded task metadata in the title
490
- if (!sess.title?.startsWith('{')) continue;
491
-
492
- try {
493
- const metadata = JSON.parse(sess.title) as {
494
- taskId?: string;
495
- agent?: string;
496
- description?: string;
497
- createdAt?: string;
498
- };
499
-
500
- // Skip if not a valid task metadata (must have taskId starting with 'bg_')
501
- if (!metadata.taskId || !metadata.taskId.startsWith('bg_')) continue;
502
-
503
- // Skip if we already have this task
504
- if (this.tasks.has(metadata.taskId)) continue;
505
-
506
- // Skip sessions without an ID
507
- if (!sess.id) continue;
508
-
509
- // Reconstruct the task
510
- const agentName = metadata.agent ?? 'unknown';
511
- const task: BackgroundTask = {
512
- id: metadata.taskId,
513
- sessionId: sess.id,
514
- parentSessionId: sess.parentID ?? '',
515
- agent: agentName,
516
- description: metadata.description ?? '',
517
- prompt: '', // Original prompt not stored in metadata
518
- status: this.mapSessionStatusToTaskStatus(sess),
519
- queuedAt: metadata.createdAt ? new Date(metadata.createdAt) : new Date(),
520
- startedAt: metadata.createdAt ? new Date(metadata.createdAt) : new Date(),
521
- concurrencyGroup: this.getConcurrencyGroup(agentName),
522
- progress: {
523
- toolCalls: 0,
524
- lastUpdate: new Date(),
525
- activeToolCallsInFlight: 0,
526
- },
527
- };
528
-
529
- // Mark recovered terminal tasks as already notified
530
- if (
531
- task.status === 'completed' ||
532
- task.status === 'error' ||
533
- task.status === 'cancelled'
534
- ) {
535
- task.notifiedStatuses = new Set([task.status]);
536
- }
537
-
538
- // Add to our tracking maps
539
- this.tasks.set(task.id, task);
540
- this.tasksBySession.set(sess.id, task.id);
541
-
542
- if (task.parentSessionId) {
543
- const parentTasks = this.tasksByParent.get(task.parentSessionId) ?? new Set();
544
- parentTasks.add(task.id);
545
- this.tasksByParent.set(task.parentSessionId, parentTasks);
546
- }
547
-
548
- recovered++;
549
- } catch {
550
- // Not valid JSON or not our task, skip
551
- continue;
552
- }
553
- }
554
- } catch (error) {
555
- console.error('Failed to recover tasks:', error);
556
- }
557
-
558
- return recovered;
559
- }
560
-
561
- private mapSessionStatusToTaskStatus(session: unknown): BackgroundTaskStatus {
562
- // Map OpenCode session status to our task status
563
- // Session status types: 'idle' | 'pending' | 'running' | 'compacting' | 'error'
564
- const status = (session as { status?: { type?: string } })?.status?.type;
565
- switch (status) {
566
- case 'idle':
567
- return 'completed';
568
- case 'pending':
569
- return 'pending';
570
- case 'running':
571
- case 'compacting': // Session is compacting context — still actively running
572
- return 'running';
573
- case 'error':
574
- return 'error';
575
- default:
576
- // Unknown session status - default to pending for best-effort recovery.
577
- // Note: refreshStatuses() guards terminal tasks before calling this,
578
- // so a 'pending' default can never downgrade a completed/errored task.
579
- return 'pending';
580
- }
581
- }
582
-
583
- cancel(taskId: string): boolean {
584
- const task = this.tasks.get(taskId);
585
- if (!task || task.status === 'completed' || task.status === 'error') {
586
- return false;
587
- }
588
-
589
- task.status = 'cancelled';
590
- task.completedAt = new Date();
591
- this.releaseConcurrency(task);
592
- this.markForNotification(task);
593
-
594
- if (task.sessionId) {
595
- void this.abortSession(task.sessionId);
596
- this.callbacks?.onSubagentSessionDeleted?.({ sessionId: task.sessionId });
597
- }
598
-
599
- return true;
600
- }
601
-
602
- handleEvent(event: EventPayload): void {
603
- if (!event || typeof event.type !== 'string') return;
604
-
605
- this.expireStaleTasks();
606
-
607
- if (event.type === 'message.part.updated') {
608
- const part = event.properties?.part as MessagePart | undefined;
609
- if (!part) return;
610
- const sessionId = part.sessionID;
611
- if (!sessionId) return;
612
- const task = this.findBySession(sessionId);
613
- if (!task) return;
614
- this.updateProgress(task, part);
615
- return;
616
- }
617
-
618
- if (event.type === 'session.idle') {
619
- const sessionId = extractSessionId(event.properties);
620
- const task = sessionId ? this.findBySession(sessionId) : undefined;
621
- if (!task) return;
622
- void this.completeTask(task);
623
- return;
624
- }
625
-
626
- if (event.type === 'session.error') {
627
- const sessionId = extractSessionId(event.properties);
628
- const task = sessionId ? this.findBySession(sessionId) : undefined;
629
- if (!task) return;
630
- const error = extractError(event.properties);
631
- const errorMsg = error ?? 'Session error.';
632
-
633
- this.failTask(task, errorMsg);
634
- return;
635
- }
636
- }
637
-
638
- markForNotification(task: BackgroundTask): void {
639
- // Monitor tasks are infrastructure — never notify Lead about them.
640
- // Monitor pushes its own consolidated report as its final output.
641
- if (task.isMonitor) return;
642
- const sessionId = task.parentSessionId;
643
- if (!sessionId) return;
644
- const queue = this.notifications.get(sessionId) ?? new Set<string>();
645
- queue.add(task.id);
646
- this.notifications.set(sessionId, queue);
647
- }
648
-
649
- getPendingNotifications(sessionId: string): BackgroundTask[] {
650
- const queue = this.notifications.get(sessionId);
651
- if (!queue) return [];
652
- return Array.from(queue)
653
- .map((id) => this.tasks.get(id))
654
- .filter((task): task is BackgroundTask => Boolean(task));
655
- }
656
-
657
- clearNotifications(sessionId: string): void {
658
- this.notifications.delete(sessionId);
659
- }
660
-
661
- shutdown(): void {
662
- this.shuttingDown = true;
663
- if (this.refreshIntervalId) {
664
- clearInterval(this.refreshIntervalId);
665
- this.refreshIntervalId = undefined;
666
- }
667
- this.concurrency.clear();
668
- this.notifications.clear();
669
- try {
670
- void this.callbacks?.onShutdown?.();
671
- } catch {
672
- // Ignore shutdown callback errors
673
- }
674
- }
675
-
676
- private indexTask(task: BackgroundTask): void {
677
- const parentList = this.tasksByParent.get(task.parentSessionId) ?? new Set<string>();
678
- parentList.add(task.id);
679
- this.tasksByParent.set(task.parentSessionId, parentList);
680
- }
681
-
682
- /**
683
- * Ensure a Monitor agent is watching all background tasks for the given parent session.
684
- *
685
- * Called automatically whenever a new background task is launched. If a Monitor is
686
- * already running for this parent, this is a no-op. The Monitor uses
687
- * `agentuity_session_dashboard({ session_id: parentSessionId })` which is scoped
688
- * to child sessions of that parent only — it does not see unrelated sessions.
689
- *
690
- * The Monitor pushes a consolidated status update to Lead when all tasks complete,
691
- * so Lead doesn't need to self-poll.
692
- */
693
- private async ensureMonitorForParent(parentSessionId: string): Promise<void> {
694
- if (this.shuttingDown) return;
695
-
696
- // Check if we already have a live monitor for this parent
697
- const existingMonitorId = this.monitorsPerParent.get(parentSessionId);
698
- if (existingMonitorId) {
699
- const existing = this.tasks.get(existingMonitorId);
700
- if (existing && (existing.status === 'pending' || existing.status === 'running')) {
701
- return; // Monitor already active
702
- }
703
- }
704
-
705
- // Find the Monitor agent display name
706
- const monitorAgent = Object.values(agents).find((a) => a.role === 'monitor');
707
- if (!monitorAgent) return; // Monitor agent not registered
708
-
709
- const monitorPrompt = `You are watching background tasks for parent session: ${parentSessionId}
710
-
711
- Use \`agentuity_session_dashboard({ session_id: "${parentSessionId}" })\` to see all child task sessions and their current status.
712
-
713
- Monitor all non-monitor background tasks until they complete. When all tasks are done (completed, error, or cancelled), send a consolidated summary back. Use \`agentuity_background_output\` to retrieve results for completed tasks.
714
-
715
- Do not poll more than once every 30 seconds. Be patient — Scout tasks reading large codebases typically take 3–8 minutes.`;
716
-
717
- try {
718
- const monitorTask: BackgroundTask = {
719
- id: createTaskId(),
720
- parentSessionId,
721
- description: 'Monitor background tasks',
722
- prompt: monitorPrompt,
723
- agent: monitorAgent.displayName,
724
- status: 'pending',
725
- queuedAt: new Date(),
726
- // Monitor uses a dedicated concurrency lane so it can never be blocked
727
- // by the tasks it's watching. If Monitor queued behind regular tasks it
728
- // would never start, and Lead would receive no consolidated report.
729
- concurrencyGroup: 'monitor',
730
- notifiedStatuses: new Set(),
731
- isMonitor: true,
732
- };
733
-
734
- this.tasks.set(monitorTask.id, monitorTask);
735
- this.monitorsPerParent.set(parentSessionId, monitorTask.id);
736
- // Index monitor task so it's tracked by parent (but flagged as monitor)
737
- this.indexTask(monitorTask);
738
-
739
- void this.startTask(monitorTask);
740
- } catch {
741
- // Non-fatal: if monitor launch fails, the event-driven notifyParent
742
- // still works as the primary completion signal
743
- }
744
- }
745
-
746
- private async startTask(task: BackgroundTask): Promise<void> {
747
- if (this.shuttingDown) return;
748
-
749
- // Use task.concurrencyGroup if explicitly set (e.g. 'monitor' for the auto-launched
750
- // Monitor agent), otherwise derive from the agent name. This lets Monitor run in its
751
- // own concurrency lane so it can never be blocked by the tasks it's watching.
752
- const concurrencyKey = task.concurrencyGroup ?? this.getConcurrencyKey(task.agent);
753
- task.concurrencyKey = concurrencyKey;
754
-
755
- try {
756
- await this.concurrency.acquire(concurrencyKey);
757
- } catch (error) {
758
- if (task.status !== 'cancelled') {
759
- task.status = 'error';
760
- task.error = extractErrorMessage(error, 'Failed to acquire slot.');
761
- task.completedAt = new Date();
762
- this.markForNotification(task);
763
- }
764
- return;
765
- }
766
-
767
- if (task.status === 'cancelled') {
768
- this.releaseConcurrency(task);
769
- return;
770
- }
771
-
772
- try {
773
- // Store task metadata in session title for persistence/recovery
774
- const taskMetadata = JSON.stringify({
775
- taskId: task.id,
776
- agent: task.agent,
777
- description: task.description,
778
- createdAt: task.queuedAt?.toISOString() ?? new Date().toISOString(),
779
- });
780
-
781
- const sessionResult = await this.ctx.client.session.create({
782
- body: {
783
- parentID: task.parentSessionId,
784
- title: taskMetadata,
785
- },
786
- throwOnError: true,
787
- ...this.getClientOverrides(),
788
- });
789
- const session = unwrapResponse<{ id: string }>(sessionResult);
790
- if (!session?.id) {
791
- throw new Error('Failed to create session.');
792
- }
793
-
794
- task.sessionId = session.id;
795
- task.status = 'running';
796
- task.startedAt = new Date();
797
- this.tasksBySession.set(session.id, task.id);
798
- this.callbacks?.onSubagentSessionCreated?.({
799
- sessionId: session.id,
800
- parentId: task.parentSessionId,
801
- title: task.description,
802
- });
803
-
804
- await this.ctx.client.session.prompt({
805
- path: { id: session.id },
806
- body: {
807
- agent: task.agent,
808
- parts: [{ type: 'text', text: task.prompt }],
809
- },
810
- throwOnError: true,
811
- ...this.getClientOverrides(),
812
- });
813
- } catch (error) {
814
- const errorMsg = extractErrorMessage(error, 'Failed to launch background task.');
815
- // Log the actual error for debugging — critical in sandbox environments
816
- // where the client may silently fail due to missing baseUrl
817
- try {
818
- void this.ctx.client.app.log({
819
- body: {
820
- service: 'agentuity-coder',
821
- level: 'error',
822
- message: `Background task ${task.id} failed to start: ${errorMsg}`,
823
- },
824
- ...this.getClientOverrides(),
825
- });
826
- } catch {
827
- // If logging also fails, fall back to console
828
- console.error(`[BackgroundManager] Task ${task.id} failed to start:`, errorMsg);
829
- }
830
- this.failTask(task, errorMsg);
831
- }
832
- }
833
-
834
- private updateProgress(task: BackgroundTask, part: MessagePart): void {
835
- const progress = task.progress ?? this.createProgress();
836
- progress.lastUpdate = new Date();
837
-
838
- if (part.type === 'tool') {
839
- const callId = part.callID;
840
- const toolName = part.tool;
841
- const toolStatus = part.state?.status;
842
-
843
- if (toolName) {
844
- progress.lastTool = toolName;
845
- }
846
-
847
- if (callId) {
848
- const seen = this.toolCallIds.get(task.id) ?? new Set<string>();
849
- const active = this.activeToolCallIds.get(task.id) ?? new Set<string>();
850
-
851
- if (!seen.has(callId)) {
852
- // First time seeing this callId — it's a new tool call starting
853
- seen.add(callId);
854
- progress.toolCalls += 1;
855
- this.toolCallIds.set(task.id, seen);
856
- }
857
-
858
- // Track in-flight status based on tool state
859
- // Only remove for explicit terminal statuses; treat unknown/missing as in-flight
860
- if (
861
- toolStatus === 'completed' ||
862
- toolStatus === 'error' ||
863
- toolStatus === 'cancelled'
864
- ) {
865
- active.delete(callId);
866
- } else {
867
- // pending, running, unknown, or missing status — treat as in-flight
868
- active.add(callId);
869
- }
870
- this.activeToolCallIds.set(task.id, active);
871
- progress.activeToolCallsInFlight = active.size;
872
- }
873
- }
874
-
875
- if (part.type === 'text' && part.text) {
876
- progress.lastMessage = part.text;
877
- progress.lastMessageAt = new Date();
878
- }
879
-
880
- task.progress = progress;
881
- }
882
-
883
- private createProgress(): TaskProgress {
884
- return {
885
- toolCalls: 0,
886
- lastUpdate: new Date(),
887
- activeToolCallsInFlight: 0,
888
- };
889
- }
890
-
891
- private async completeTask(task: BackgroundTask): Promise<void> {
892
- if (task.status !== 'running') return;
893
-
894
- task.status = 'completed';
895
- task.completedAt = new Date();
896
- this.releaseConcurrency(task);
897
-
898
- if (task.sessionId) {
899
- const result = await this.fetchLatestResult(task.sessionId);
900
- if (result) {
901
- task.result = result;
902
- }
903
- this.callbacks?.onSubagentSessionDeleted?.({ sessionId: task.sessionId });
904
- }
905
-
906
- this.markForNotification(task);
907
- void this.notifyParent(task);
908
- }
909
-
910
- private failTask(task: BackgroundTask, error: string): void {
911
- if (task.status === 'completed' || task.status === 'error') return;
912
- task.status = 'error';
913
- task.error = error;
914
- task.completedAt = new Date();
915
- this.releaseConcurrency(task);
916
- if (task.sessionId) {
917
- this.callbacks?.onSubagentSessionDeleted?.({ sessionId: task.sessionId });
918
- }
919
- this.markForNotification(task);
920
- void this.notifyParent(task);
921
- }
922
-
923
- private releaseConcurrency(task: BackgroundTask): void {
924
- if (!task.concurrencyKey) return;
925
- this.concurrency.release(task.concurrencyKey);
926
- delete task.concurrencyKey;
927
- }
928
-
929
- private async notifyParent(task: BackgroundTask): Promise<void> {
930
- if (!task.parentSessionId) return;
931
- if (this.shuttingDown) return;
932
- // Monitor tasks push their own report as their session output — no separate notification needed.
933
- if (task.isMonitor) return;
934
-
935
- // Recovered tasks (from recoverTasks) have no notifiedStatuses.
936
- // Assume they were already notified and skip to prevent duplicate notifications.
937
- if (!task.notifiedStatuses) {
938
- task.notifiedStatuses = new Set([task.status]);
939
- return;
940
- }
941
-
942
- // Snapshot status at call-time to prevent race conditions where concurrent
943
- // refreshStatuses() calls mutate task.status during our async awaits below.
944
- // Without this, the wrong status could be recorded as notified after delivery.
945
- const statusAtCallTime = task.status;
946
-
947
- const notifiedStatuses = task.notifiedStatuses;
948
- if (notifiedStatuses.has(statusAtCallTime)) {
949
- return; // Already notified for this status, skip duplicate
950
- }
951
-
952
- // Belt-and-suspenders: rate limit notifications per task+status to 1 per 10s
953
- const now = Date.now();
954
- const lastNotifyKey = `${task.id}:${statusAtCallTime}`;
955
- const lastTime = this.lastNotifyTimes.get(lastNotifyKey);
956
- if (lastTime && now - lastTime < 10_000) {
957
- return;
958
- }
959
- this.lastNotifyTimes.set(lastNotifyKey, now);
960
-
961
- // Do NOT pre-mark as notified here — if all retries fail, the status
962
- // must remain unmarked so future retry attempts (via refreshStatuses
963
- // or Monitor) are not blocked. Mark only on confirmed delivery below.
964
-
965
- const message = `[BACKGROUND TASK ${statusAtCallTime.toUpperCase()}]
966
-
967
- Task: ${task.description}
968
- Agent: ${task.agent}
969
- Status: ${statusAtCallTime}
970
- Task ID: ${task.id}
971
-
972
- Use the agentuity_background_output tool with task_id "${task.id}" to view the result.`;
973
-
974
- const maxRetries = 3;
975
- for (let attempt = 0; attempt < maxRetries; attempt++) {
976
- try {
977
- await this.ctx.client.session.prompt({
978
- path: { id: task.parentSessionId },
979
- body: {
980
- parts: [{ type: 'text', text: message }],
981
- },
982
- throwOnError: true,
983
- responseStyle: 'data',
984
- ...this.getClientOverrides(),
985
- });
986
- // Mark the snapshotted status as notified only AFTER confirmed delivery.
987
- // Using the snapshot prevents recording the wrong status if task.status
988
- // was mutated concurrently during the await above.
989
- notifiedStatuses.add(statusAtCallTime);
990
- task.notifiedStatuses = notifiedStatuses;
991
- return; // Success
992
- } catch (error) {
993
- const errorMsg = extractErrorMessage(error, 'notification failed');
994
- if (attempt < maxRetries - 1) {
995
- // Exponential backoff: 1s, 2s, 4s
996
- await new Promise((r) => setTimeout(r, 1000 * Math.pow(2, attempt)));
997
- if (this.shuttingDown) return;
998
- } else {
999
- console.error(
1000
- `[BackgroundManager] Failed to notify parent for task ${task.id} after ${maxRetries} attempts:`,
1001
- errorMsg
1002
- );
1003
- // Safety net: ensure status is NOT marked as notified so future
1004
- // retry attempts (via refreshStatuses or Monitor) are not blocked
1005
- notifiedStatuses.delete(statusAtCallTime);
1006
- }
1007
- }
1008
- }
1009
- }
1010
-
1011
- private async abortSession(sessionId: string): Promise<void> {
1012
- try {
1013
- await this.ctx.client.session.abort({
1014
- path: { id: sessionId },
1015
- throwOnError: false,
1016
- ...this.getClientOverrides(),
1017
- });
1018
- } catch {
1019
- // Ignore abort errors
1020
- }
1021
- }
1022
-
1023
- private async fetchLatestResult(sessionId: string): Promise<string | undefined> {
1024
- try {
1025
- if (this.dbReader?.isAvailable()) {
1026
- const messages = this.dbReader.getMessages(sessionId, { limit: 100, offset: 0 });
1027
- const textParts = this.dbReader.getTextParts(sessionId, { limit: 300 });
1028
- const partsByMessage = groupTextPartsByMessage(textParts);
1029
- for (const message of messages) {
1030
- if (message.role !== 'assistant') continue;
1031
- const text = joinTextParts(partsByMessage.get(message.id));
1032
- if (text) return text;
1033
- }
1034
- return undefined;
1035
- }
1036
-
1037
- const messagesResult = await this.ctx.client.session.messages({
1038
- path: { id: sessionId },
1039
- throwOnError: true,
1040
- ...this.getClientOverrides(),
1041
- });
1042
- const messages = unwrapResponse<Array<unknown>>(messagesResult) ?? [];
1043
- const entries = Array.isArray(messages) ? messages : [];
1044
- for (let i = entries.length - 1; i >= 0; i -= 1) {
1045
- const entry = entries[i] as { info?: { role?: string }; parts?: Array<unknown> };
1046
- if (entry?.info?.role !== 'assistant') continue;
1047
- const text = extractTextFromParts(entry.parts ?? []);
1048
- if (text) return text;
1049
- }
1050
- } catch {
1051
- return undefined;
1052
- }
1053
-
1054
- return undefined;
1055
- }
1056
-
1057
- private mapDbStatusToTaskStatus(sessionId: string): BackgroundTaskStatus {
1058
- if (!this.dbReader) return 'pending';
1059
- const status = this.dbReader.getSessionStatus(sessionId).status;
1060
- switch (status) {
1061
- case 'idle':
1062
- case 'archived':
1063
- return 'completed';
1064
- case 'active':
1065
- case 'compacting':
1066
- return 'running';
1067
- case 'error':
1068
- return 'error';
1069
- default:
1070
- return 'pending';
1071
- }
1072
- }
1073
-
1074
- private getConcurrencyGroup(agentName: string): string | undefined {
1075
- const model = getAgentModel(agentName);
1076
- if (!model) return undefined;
1077
- const provider = model.split('/')[0];
1078
- if (model && this.config.modelConcurrency?.[model] !== undefined) {
1079
- return `model:${model}`;
1080
- }
1081
- if (provider && this.config.providerConcurrency?.[provider] !== undefined) {
1082
- return `provider:${provider}`;
1083
- }
1084
- return undefined;
1085
- }
1086
-
1087
- private getConcurrencyKey(agentName: string): string {
1088
- const group = this.getConcurrencyGroup(agentName);
1089
- return group ?? 'default';
1090
- }
1091
-
1092
- private expireStaleTasks(): void {
1093
- const now = Date.now();
1094
- for (const task of this.tasks.values()) {
1095
- if (task.status !== 'pending' && task.status !== 'running') continue;
1096
- // Use last activity time (last event received) rather than start time.
1097
- // A task actively doing tool calls every minute should never expire —
1098
- // only tasks that have gone silent for staleTimeoutMs should be killed.
1099
- const lastActivity =
1100
- task.progress?.lastUpdate.getTime() ??
1101
- task.startedAt?.getTime() ??
1102
- task.queuedAt?.getTime();
1103
- if (!lastActivity) continue;
1104
- if (now - lastActivity > this.config.staleTimeoutMs) {
1105
- this.failTask(task, 'Background task timed out (no activity).');
1106
- }
1107
- }
1108
- }
1109
- }
1110
-
1111
- function buildConcurrencyLimits(config: BackgroundTaskConfig): Record<string, number> {
1112
- const limits: Record<string, number> = {};
1113
- if (config.providerConcurrency) {
1114
- for (const [provider, limit] of Object.entries(config.providerConcurrency)) {
1115
- limits[`provider:${provider}`] = limit;
1116
- }
1117
- }
1118
- if (config.modelConcurrency) {
1119
- for (const [model, limit] of Object.entries(config.modelConcurrency)) {
1120
- limits[`model:${model}`] = limit;
1121
- }
1122
- }
1123
- return limits;
1124
- }
1125
-
1126
- function getAgentModel(agentName: string): string | undefined {
1127
- const agent = findAgentDefinition(agentName);
1128
- return agent?.defaultModel;
1129
- }
1130
-
1131
- function findAgentDefinition(agentName: string): AgentDefinition | undefined {
1132
- return Object.values(agents).find(
1133
- (agent) =>
1134
- agent.displayName === agentName || agent.id === agentName || agent.role === agentName
1135
- );
1136
- }
1137
-
1138
- function createTaskId(): string {
1139
- return `bg_${Math.random().toString(36).slice(2, 8)}`;
1140
- }
1141
-
1142
- function extractSessionId(properties?: Record<string, unknown>): string | undefined {
1143
- return (
1144
- (properties?.sessionId as string | undefined) ?? (properties?.sessionID as string | undefined)
1145
- );
1146
- }
1147
-
1148
- function extractError(properties?: Record<string, unknown>): string | undefined {
1149
- const error = properties?.error as { data?: { message?: string }; name?: string } | undefined;
1150
- return error?.data?.message ?? (typeof error?.name === 'string' ? error.name : undefined);
1151
- }
1152
-
1153
- function extractTextFromParts(parts: Array<unknown>): string | undefined {
1154
- const textParts: string[] = [];
1155
- for (const part of parts) {
1156
- if (typeof part !== 'object' || part === null) continue;
1157
- const typed = part as { type?: string; text?: string };
1158
- if (typed.type === 'text' && typeof typed.text === 'string') {
1159
- textParts.push(typed.text);
1160
- }
1161
- }
1162
- if (textParts.length === 0) return undefined;
1163
- return textParts.join('\n');
1164
- }
1165
-
1166
- function groupTextPartsByMessage(parts: DBTextPart[]): Map<string, DBTextPart[]> {
1167
- const grouped = new Map<string, DBTextPart[]>();
1168
- for (const part of parts) {
1169
- const list = grouped.get(part.messageId) ?? [];
1170
- list.push(part);
1171
- grouped.set(part.messageId, list);
1172
- }
1173
- for (const list of grouped.values()) {
1174
- list.sort((a, b) => a.timeCreated - b.timeCreated);
1175
- }
1176
- return grouped;
1177
- }
1178
-
1179
- function buildTextParts(parts?: DBTextPart[]): Array<{ type: string; text: string }> {
1180
- if (!parts || parts.length === 0) return [];
1181
- return parts.map((part) => ({ type: 'text', text: part.text }));
1182
- }
1183
-
1184
- function joinTextParts(parts?: DBTextPart[]): string | undefined {
1185
- if (!parts || parts.length === 0) return undefined;
1186
- return parts.map((part) => part.text).join('\n');
1187
- }
1188
-
1189
- function unwrapResponse<T>(result: unknown): T | undefined {
1190
- if (typeof result === 'object' && result !== null && 'data' in result) {
1191
- return (result as { data?: T }).data;
1192
- }
1193
- return result as T;
1194
- }
1195
-
1196
- /**
1197
- * Extract an error message from an unknown thrown value.
1198
- *
1199
- * The OpenCode SDK client (with `throwOnError: true`) throws **plain objects**
1200
- * (e.g. `{ message: "Not Found" }`) or raw strings rather than `Error` instances.
1201
- * This helper normalises all shapes into a usable string.
1202
- */
1203
- function extractErrorMessage(error: unknown, fallback: string): string {
1204
- if (error instanceof Error) return error.message;
1205
- if (typeof error === 'string') return error || fallback;
1206
- if (typeof error === 'object' && error !== null) {
1207
- const obj = error as Record<string, unknown>;
1208
- if (typeof obj.message === 'string') return obj.message || fallback;
1209
- if (typeof obj.error === 'string') return obj.error || fallback;
1210
- if (typeof obj.error === 'object' && obj.error !== null) {
1211
- return extractErrorMessage(obj.error, fallback);
1212
- }
1213
- }
1214
- return fallback;
1215
- }