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