@agentuity/opencode 1.0.16 → 1.0.18

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 (113) hide show
  1. package/dist/agents/architect.d.ts +1 -1
  2. package/dist/agents/architect.d.ts.map +1 -1
  3. package/dist/agents/architect.js +30 -33
  4. package/dist/agents/architect.js.map +1 -1
  5. package/dist/agents/builder.d.ts +1 -1
  6. package/dist/agents/builder.d.ts.map +1 -1
  7. package/dist/agents/builder.js +53 -60
  8. package/dist/agents/builder.js.map +1 -1
  9. package/dist/agents/expert-backend.d.ts +1 -1
  10. package/dist/agents/expert-backend.d.ts.map +1 -1
  11. package/dist/agents/expert-backend.js +31 -39
  12. package/dist/agents/expert-backend.js.map +1 -1
  13. package/dist/agents/expert-frontend.d.ts +1 -1
  14. package/dist/agents/expert-frontend.d.ts.map +1 -1
  15. package/dist/agents/expert-frontend.js +17 -23
  16. package/dist/agents/expert-frontend.js.map +1 -1
  17. package/dist/agents/expert-ops.d.ts +1 -1
  18. package/dist/agents/expert-ops.d.ts.map +1 -1
  19. package/dist/agents/expert-ops.js +36 -50
  20. package/dist/agents/expert-ops.js.map +1 -1
  21. package/dist/agents/expert.d.ts +1 -1
  22. package/dist/agents/expert.d.ts.map +1 -1
  23. package/dist/agents/expert.js +32 -42
  24. package/dist/agents/expert.js.map +1 -1
  25. package/dist/agents/lead.d.ts +1 -1
  26. package/dist/agents/lead.d.ts.map +1 -1
  27. package/dist/agents/lead.js +182 -225
  28. package/dist/agents/lead.js.map +1 -1
  29. package/dist/agents/memory.d.ts +1 -1
  30. package/dist/agents/memory.d.ts.map +1 -1
  31. package/dist/agents/memory.js +62 -90
  32. package/dist/agents/memory.js.map +1 -1
  33. package/dist/agents/monitor.d.ts +1 -1
  34. package/dist/agents/monitor.d.ts.map +1 -1
  35. package/dist/agents/monitor.js +93 -42
  36. package/dist/agents/monitor.js.map +1 -1
  37. package/dist/agents/product.d.ts +1 -1
  38. package/dist/agents/product.d.ts.map +1 -1
  39. package/dist/agents/product.js +16 -22
  40. package/dist/agents/product.js.map +1 -1
  41. package/dist/agents/reviewer.d.ts +1 -1
  42. package/dist/agents/reviewer.d.ts.map +1 -1
  43. package/dist/agents/reviewer.js +14 -26
  44. package/dist/agents/reviewer.js.map +1 -1
  45. package/dist/agents/runner.d.ts +1 -1
  46. package/dist/agents/runner.d.ts.map +1 -1
  47. package/dist/agents/runner.js +52 -76
  48. package/dist/agents/runner.js.map +1 -1
  49. package/dist/agents/scout.d.ts +1 -1
  50. package/dist/agents/scout.d.ts.map +1 -1
  51. package/dist/agents/scout.js +41 -42
  52. package/dist/agents/scout.js.map +1 -1
  53. package/dist/agents/types.d.ts +8 -0
  54. package/dist/agents/types.d.ts.map +1 -1
  55. package/dist/background/manager.d.ts +17 -0
  56. package/dist/background/manager.d.ts.map +1 -1
  57. package/dist/background/manager.js +176 -19
  58. package/dist/background/manager.js.map +1 -1
  59. package/dist/background/types.d.ts +3 -0
  60. package/dist/background/types.d.ts.map +1 -1
  61. package/dist/config/loader.js +2 -2
  62. package/dist/plugin/hooks/cadence.d.ts.map +1 -1
  63. package/dist/plugin/hooks/cadence.js +5 -9
  64. package/dist/plugin/hooks/cadence.js.map +1 -1
  65. package/dist/plugin/hooks/completion.d.ts +14 -0
  66. package/dist/plugin/hooks/completion.d.ts.map +1 -0
  67. package/dist/plugin/hooks/completion.js +60 -0
  68. package/dist/plugin/hooks/completion.js.map +1 -0
  69. package/dist/plugin/hooks/params.d.ts +46 -1
  70. package/dist/plugin/hooks/params.d.ts.map +1 -1
  71. package/dist/plugin/hooks/params.js +77 -0
  72. package/dist/plugin/hooks/params.js.map +1 -1
  73. package/dist/plugin/hooks/session-memory.d.ts.map +1 -1
  74. package/dist/plugin/hooks/session-memory.js +4 -0
  75. package/dist/plugin/hooks/session-memory.js.map +1 -1
  76. package/dist/plugin/hooks/tools.d.ts.map +1 -1
  77. package/dist/plugin/hooks/tools.js +26 -1
  78. package/dist/plugin/hooks/tools.js.map +1 -1
  79. package/dist/plugin/plugin.d.ts.map +1 -1
  80. package/dist/plugin/plugin.js +9 -2
  81. package/dist/plugin/plugin.js.map +1 -1
  82. package/dist/tools/background.d.ts.map +1 -1
  83. package/dist/tools/background.js +15 -0
  84. package/dist/tools/background.js.map +1 -1
  85. package/dist/types.d.ts +10 -0
  86. package/dist/types.d.ts.map +1 -1
  87. package/dist/types.js.map +1 -1
  88. package/package.json +3 -3
  89. package/src/agents/architect.ts +30 -33
  90. package/src/agents/builder.ts +53 -60
  91. package/src/agents/expert-backend.ts +31 -39
  92. package/src/agents/expert-frontend.ts +17 -23
  93. package/src/agents/expert-ops.ts +36 -50
  94. package/src/agents/expert.ts +32 -42
  95. package/src/agents/lead.ts +182 -225
  96. package/src/agents/memory.ts +62 -90
  97. package/src/agents/monitor.ts +93 -42
  98. package/src/agents/product.ts +16 -22
  99. package/src/agents/reviewer.ts +14 -26
  100. package/src/agents/runner.ts +52 -76
  101. package/src/agents/scout.ts +41 -42
  102. package/src/agents/types.ts +8 -0
  103. package/src/background/manager.ts +198 -19
  104. package/src/background/types.ts +3 -0
  105. package/src/config/loader.ts +2 -2
  106. package/src/plugin/hooks/cadence.ts +5 -9
  107. package/src/plugin/hooks/completion.ts +81 -0
  108. package/src/plugin/hooks/params.ts +97 -1
  109. package/src/plugin/hooks/session-memory.ts +4 -0
  110. package/src/plugin/hooks/tools.ts +32 -1
  111. package/src/plugin/plugin.ts +9 -2
  112. package/src/tools/background.ts +28 -0
  113. package/src/types.ts +10 -0
@@ -55,6 +55,11 @@ export class BackgroundManager {
55
55
  private tasksBySession = new Map<string, string>();
56
56
  private notifications = new Map<string, Set<string>>();
57
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>();
58
63
  private shuttingDown = false;
59
64
  private refreshIntervalId: ReturnType<typeof setInterval> | undefined;
60
65
 
@@ -162,6 +167,12 @@ export class BackgroundManager {
162
167
  }
163
168
 
164
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
+
165
176
  return task;
166
177
  }
167
178
 
@@ -189,7 +200,21 @@ export class BackgroundManager {
189
200
  */
190
201
  async inspectTask(taskId: string): Promise<TaskInspection | undefined> {
191
202
  const task = this.tasks.get(taskId);
192
- if (!task?.sessionId) return undefined;
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
+ }
193
218
 
194
219
  try {
195
220
  if (this.dbReader?.isAvailable()) {
@@ -324,6 +349,17 @@ export class BackgroundManager {
324
349
  if (matchedTaskId) {
325
350
  const task = this.tasks.get(matchedTaskId);
326
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
+ }
327
363
  const newStatus = this.mapSessionStatusToTaskStatus(childSession);
328
364
  if (newStatus !== task.status) {
329
365
  // Use proper handlers to trigger side effects (concurrency, notifications, etc.)
@@ -400,6 +436,7 @@ export class BackgroundManager {
400
436
  progress: {
401
437
  toolCalls: 0,
402
438
  lastUpdate: new Date(),
439
+ activeToolCallsInFlight: 0,
403
440
  },
404
441
  };
405
442
 
@@ -485,6 +522,7 @@ export class BackgroundManager {
485
522
  progress: {
486
523
  toolCalls: 0,
487
524
  lastUpdate: new Date(),
525
+ activeToolCallsInFlight: 0,
488
526
  },
489
527
  };
490
528
 
@@ -522,7 +560,7 @@ export class BackgroundManager {
522
560
 
523
561
  private mapSessionStatusToTaskStatus(session: unknown): BackgroundTaskStatus {
524
562
  // Map OpenCode session status to our task status
525
- // Session status types: 'idle' | 'pending' | 'running' | 'error'
563
+ // Session status types: 'idle' | 'pending' | 'running' | 'compacting' | 'error'
526
564
  const status = (session as { status?: { type?: string } })?.status?.type;
527
565
  switch (status) {
528
566
  case 'idle':
@@ -530,11 +568,14 @@ export class BackgroundManager {
530
568
  case 'pending':
531
569
  return 'pending';
532
570
  case 'running':
571
+ case 'compacting': // Session is compacting context — still actively running
533
572
  return 'running';
534
573
  case 'error':
535
574
  return 'error';
536
575
  default:
537
- // Unknown session status - default to pending for best-effort recovery
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.
538
579
  return 'pending';
539
580
  }
540
581
  }
@@ -587,12 +628,28 @@ export class BackgroundManager {
587
628
  const task = sessionId ? this.findBySession(sessionId) : undefined;
588
629
  if (!task) return;
589
630
  const error = extractError(event.properties);
590
- this.failTask(task, error ?? 'Session error.');
631
+ const errorMsg = error ?? 'Session error.';
632
+
633
+ // Log extra context for timeout errors — the server fires these when
634
+ // a model generates a long text response without tool activity.
635
+ if (
636
+ errorMsg.toLowerCase().includes('timeout') ||
637
+ errorMsg.toLowerCase().includes('no activity')
638
+ ) {
639
+ console.debug(
640
+ `[BackgroundManager] Task ${task.id} timed out - may have been generating long response. Progress: ${JSON.stringify(task.progress)}`
641
+ );
642
+ }
643
+
644
+ this.failTask(task, errorMsg);
591
645
  return;
592
646
  }
593
647
  }
594
648
 
595
649
  markForNotification(task: BackgroundTask): void {
650
+ // Monitor tasks are infrastructure — never notify Lead about them.
651
+ // Monitor pushes its own consolidated report as its final output.
652
+ if (task.isMonitor) return;
596
653
  const sessionId = task.parentSessionId;
597
654
  if (!sessionId) return;
598
655
  const queue = this.notifications.get(sessionId) ?? new Set<string>();
@@ -633,10 +690,77 @@ export class BackgroundManager {
633
690
  this.tasksByParent.set(task.parentSessionId, parentList);
634
691
  }
635
692
 
693
+ /**
694
+ * Ensure a Monitor agent is watching all background tasks for the given parent session.
695
+ *
696
+ * Called automatically whenever a new background task is launched. If a Monitor is
697
+ * already running for this parent, this is a no-op. The Monitor uses
698
+ * `agentuity_session_dashboard({ session_id: parentSessionId })` which is scoped
699
+ * to child sessions of that parent only — it does not see unrelated sessions.
700
+ *
701
+ * The Monitor pushes a consolidated status update to Lead when all tasks complete,
702
+ * so Lead doesn't need to self-poll.
703
+ */
704
+ private async ensureMonitorForParent(parentSessionId: string): Promise<void> {
705
+ if (this.shuttingDown) return;
706
+
707
+ // Check if we already have a live monitor for this parent
708
+ const existingMonitorId = this.monitorsPerParent.get(parentSessionId);
709
+ if (existingMonitorId) {
710
+ const existing = this.tasks.get(existingMonitorId);
711
+ if (existing && (existing.status === 'pending' || existing.status === 'running')) {
712
+ return; // Monitor already active
713
+ }
714
+ }
715
+
716
+ // Find the Monitor agent display name
717
+ const monitorAgent = Object.values(agents).find((a) => a.role === 'monitor');
718
+ if (!monitorAgent) return; // Monitor agent not registered
719
+
720
+ const monitorPrompt = `You are watching background tasks for parent session: ${parentSessionId}
721
+
722
+ Use \`agentuity_session_dashboard({ session_id: "${parentSessionId}" })\` to see all child task sessions and their current status.
723
+
724
+ 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.
725
+
726
+ Do not poll more than once every 30 seconds. Be patient — Scout tasks reading large codebases typically take 3–8 minutes.`;
727
+
728
+ try {
729
+ const monitorTask: BackgroundTask = {
730
+ id: createTaskId(),
731
+ parentSessionId,
732
+ description: 'Monitor background tasks',
733
+ prompt: monitorPrompt,
734
+ agent: monitorAgent.displayName,
735
+ status: 'pending',
736
+ queuedAt: new Date(),
737
+ // Monitor uses a dedicated concurrency lane so it can never be blocked
738
+ // by the tasks it's watching. If Monitor queued behind regular tasks it
739
+ // would never start, and Lead would receive no consolidated report.
740
+ concurrencyGroup: 'monitor',
741
+ notifiedStatuses: new Set(),
742
+ isMonitor: true,
743
+ };
744
+
745
+ this.tasks.set(monitorTask.id, monitorTask);
746
+ this.monitorsPerParent.set(parentSessionId, monitorTask.id);
747
+ // Index monitor task so it's tracked by parent (but flagged as monitor)
748
+ this.indexTask(monitorTask);
749
+
750
+ void this.startTask(monitorTask);
751
+ } catch {
752
+ // Non-fatal: if monitor launch fails, the event-driven notifyParent
753
+ // still works as the primary completion signal
754
+ }
755
+ }
756
+
636
757
  private async startTask(task: BackgroundTask): Promise<void> {
637
758
  if (this.shuttingDown) return;
638
759
 
639
- const concurrencyKey = this.getConcurrencyKey(task.agent);
760
+ // Use task.concurrencyGroup if explicitly set (e.g. 'monitor' for the auto-launched
761
+ // Monitor agent), otherwise derive from the agent name. This lets Monitor run in its
762
+ // own concurrency lane so it can never be blocked by the tasks it's watching.
763
+ const concurrencyKey = task.concurrencyGroup ?? this.getConcurrencyKey(task.agent);
640
764
  task.concurrencyKey = concurrencyKey;
641
765
 
642
766
  try {
@@ -725,16 +849,37 @@ export class BackgroundManager {
725
849
  if (part.type === 'tool') {
726
850
  const callId = part.callID;
727
851
  const toolName = part.tool;
852
+ const toolStatus = part.state?.status;
853
+
728
854
  if (toolName) {
729
855
  progress.lastTool = toolName;
730
856
  }
857
+
731
858
  if (callId) {
732
859
  const seen = this.toolCallIds.get(task.id) ?? new Set<string>();
860
+ const active = this.activeToolCallIds.get(task.id) ?? new Set<string>();
861
+
733
862
  if (!seen.has(callId)) {
863
+ // First time seeing this callId — it's a new tool call starting
734
864
  seen.add(callId);
735
865
  progress.toolCalls += 1;
736
866
  this.toolCallIds.set(task.id, seen);
737
867
  }
868
+
869
+ // Track in-flight status based on tool state
870
+ // Only remove for explicit terminal statuses; treat unknown/missing as in-flight
871
+ if (
872
+ toolStatus === 'completed' ||
873
+ toolStatus === 'error' ||
874
+ toolStatus === 'cancelled'
875
+ ) {
876
+ active.delete(callId);
877
+ } else {
878
+ // pending, running, unknown, or missing status — treat as in-flight
879
+ active.add(callId);
880
+ }
881
+ this.activeToolCallIds.set(task.id, active);
882
+ progress.activeToolCallsInFlight = active.size;
738
883
  }
739
884
  }
740
885
 
@@ -750,6 +895,7 @@ export class BackgroundManager {
750
895
  return {
751
896
  toolCalls: 0,
752
897
  lastUpdate: new Date(),
898
+ activeToolCallsInFlight: 0,
753
899
  };
754
900
  }
755
901
 
@@ -794,21 +940,44 @@ export class BackgroundManager {
794
940
  private async notifyParent(task: BackgroundTask): Promise<void> {
795
941
  if (!task.parentSessionId) return;
796
942
  if (this.shuttingDown) return;
943
+ // Monitor tasks push their own report as their session output — no separate notification needed.
944
+ if (task.isMonitor) return;
797
945
 
798
- // Prevent duplicate notifications for the same task+status combination
799
- // This guards against OpenCode firing multiple events for the same status transition
800
- const notifiedStatuses = task.notifiedStatuses ?? new Set();
946
+ // Recovered tasks (from recoverTasks) have no notifiedStatuses.
947
+ // Assume they were already notified and skip to prevent duplicate notifications.
948
+ if (!task.notifiedStatuses) {
949
+ task.notifiedStatuses = new Set([task.status]);
950
+ return;
951
+ }
801
952
 
802
- if (notifiedStatuses.has(task.status)) {
953
+ // Snapshot status at call-time to prevent race conditions where concurrent
954
+ // refreshStatuses() calls mutate task.status during our async awaits below.
955
+ // Without this, the wrong status could be recorded as notified after delivery.
956
+ const statusAtCallTime = task.status;
957
+
958
+ const notifiedStatuses = task.notifiedStatuses;
959
+ if (notifiedStatuses.has(statusAtCallTime)) {
803
960
  return; // Already notified for this status, skip duplicate
804
961
  }
805
962
 
806
- const statusLine = task.status === 'completed' ? 'completed' : task.status;
807
- const message = `[BACKGROUND TASK ${statusLine.toUpperCase()}]
963
+ // Belt-and-suspenders: rate limit notifications per task+status to 1 per 10s
964
+ const now = Date.now();
965
+ const lastNotifyKey = `${task.id}:${statusAtCallTime}`;
966
+ const lastTime = this.lastNotifyTimes.get(lastNotifyKey);
967
+ if (lastTime && now - lastTime < 10_000) {
968
+ return;
969
+ }
970
+ this.lastNotifyTimes.set(lastNotifyKey, now);
971
+
972
+ // Do NOT pre-mark as notified here — if all retries fail, the status
973
+ // must remain unmarked so future retry attempts (via refreshStatuses
974
+ // or Monitor) are not blocked. Mark only on confirmed delivery below.
975
+
976
+ const message = `[BACKGROUND TASK ${statusAtCallTime.toUpperCase()}]
808
977
 
809
978
  Task: ${task.description}
810
979
  Agent: ${task.agent}
811
- Status: ${task.status}
980
+ Status: ${statusAtCallTime}
812
981
  Task ID: ${task.id}
813
982
 
814
983
  Use the agentuity_background_output tool with task_id "${task.id}" to view the result.`;
@@ -825,8 +994,10 @@ Use the agentuity_background_output tool with task_id "${task.id}" to view the r
825
994
  responseStyle: 'data',
826
995
  ...this.getClientOverrides(),
827
996
  });
828
- // Mark as notified only AFTER confirmed delivery
829
- notifiedStatuses.add(task.status);
997
+ // Mark the snapshotted status as notified only AFTER confirmed delivery.
998
+ // Using the snapshot prevents recording the wrong status if task.status
999
+ // was mutated concurrently during the await above.
1000
+ notifiedStatuses.add(statusAtCallTime);
830
1001
  task.notifiedStatuses = notifiedStatuses;
831
1002
  return; // Success
832
1003
  } catch (error) {
@@ -840,7 +1011,9 @@ Use the agentuity_background_output tool with task_id "${task.id}" to view the r
840
1011
  `[BackgroundManager] Failed to notify parent for task ${task.id} after ${maxRetries} attempts:`,
841
1012
  errorMsg
842
1013
  );
843
- // Don't mark as notified allow future retry via refreshStatuses or Monitor
1014
+ // Safety net: ensure status is NOT marked as notified so future
1015
+ // retry attempts (via refreshStatuses or Monitor) are not blocked
1016
+ notifiedStatuses.delete(statusAtCallTime);
844
1017
  }
845
1018
  }
846
1019
  }
@@ -931,10 +1104,16 @@ Use the agentuity_background_output tool with task_id "${task.id}" to view the r
931
1104
  const now = Date.now();
932
1105
  for (const task of this.tasks.values()) {
933
1106
  if (task.status !== 'pending' && task.status !== 'running') continue;
934
- const start = task.startedAt?.getTime() ?? task.queuedAt?.getTime();
935
- if (!start) continue;
936
- if (now - start > this.config.staleTimeoutMs) {
937
- this.failTask(task, 'Background task timed out.');
1107
+ // Use last activity time (last event received) rather than start time.
1108
+ // A task actively doing tool calls every minute should never expire —
1109
+ // only tasks that have gone silent for staleTimeoutMs should be killed.
1110
+ const lastActivity =
1111
+ task.progress?.lastUpdate.getTime() ??
1112
+ task.startedAt?.getTime() ??
1113
+ task.queuedAt?.getTime();
1114
+ if (!lastActivity) continue;
1115
+ if (now - lastActivity > this.config.staleTimeoutMs) {
1116
+ this.failTask(task, 'Background task timed out (no activity).');
938
1117
  }
939
1118
  }
940
1119
  }
@@ -7,6 +7,8 @@ export interface TaskProgress {
7
7
  lastUpdate: Date;
8
8
  lastMessage?: string;
9
9
  lastMessageAt?: Date;
10
+ /** Number of tool calls currently in-flight (pending/running state) */
11
+ activeToolCallsInFlight: number;
10
12
  }
11
13
 
12
14
  export interface BackgroundTask {
@@ -27,6 +29,7 @@ export interface BackgroundTask {
27
29
  concurrencyKey?: string; // Active concurrency slot key
28
30
  concurrencyGroup?: string; // Persistent key for re-acquiring on resume
29
31
  notifiedStatuses?: Set<BackgroundTaskStatus>; // Tracks statuses already notified to prevent duplicates
32
+ isMonitor?: boolean; // True if this task is an auto-launched Monitor agent
30
33
  }
31
34
 
32
35
  export interface LaunchInput {
@@ -146,7 +146,7 @@ const DEFAULT_BLOCKED_COMMANDS = [
146
146
 
147
147
  const DEFAULT_BACKGROUND_CONFIG: BackgroundTaskConfig = {
148
148
  enabled: true,
149
- defaultConcurrency: 1,
149
+ defaultConcurrency: 5,
150
150
  staleTimeoutMs: 30 * 60 * 1000,
151
151
  providerConcurrency: {},
152
152
  modelConcurrency: {},
@@ -199,7 +199,7 @@ function mergeBackgroundConfig(
199
199
  if (!base && !override) return undefined;
200
200
  return {
201
201
  enabled: override?.enabled ?? base?.enabled ?? true,
202
- defaultConcurrency: override?.defaultConcurrency ?? base?.defaultConcurrency ?? 1,
202
+ defaultConcurrency: override?.defaultConcurrency ?? base?.defaultConcurrency ?? 5,
203
203
  staleTimeoutMs: override?.staleTimeoutMs ?? base?.staleTimeoutMs ?? 30 * 60 * 1000,
204
204
  providerConcurrency: {
205
205
  ...(base?.providerConcurrency ?? {}),
@@ -197,7 +197,10 @@ export function createCadenceHooks(
197
197
 
198
198
  log(`Event received: ${event.type}`);
199
199
 
200
- // Handle session.compacted - save compaction AND continue loop
200
+ // Handle session.compacted - save compaction AND continue loop.
201
+ // Note: Compaction continues in the SAME session (via session.prompt with
202
+ // the existing sessionId), so permissions configured in the config hook
203
+ // (plugin.ts) are automatically inherited — no re-application needed.
201
204
  if (event.type === 'session.compacted') {
202
205
  const sessionId = event.sessionId;
203
206
  if (!sessionId) return;
@@ -379,14 +382,7 @@ ${taskList}
379
382
  Use \`agentuity_background_output({ task_id: "..." })\` to check their status.
380
383
  Use \`agentuity_session_dashboard({ session_id: "..." })\` to get a full session tree with status, costs, and health summary for Lead-of-Leads monitoring.
381
384
 
382
- **Tip:** If you spawned child Leads for parallel work, delegate monitoring to BackgroundMonitor:
383
- \`\`\`typescript
384
- agentuity_background_task({
385
- agent: "monitor",
386
- task: "Monitor these background tasks and report when all complete:\\n${tasks.map((t) => `- ${t.id}`).join('\\n')}",
387
- description: "Monitor child tasks"
388
- })
389
- \`\`\``;
385
+ **Tip:** A Monitor agent is auto-launched to watch these tasks. You will receive \`[BACKGROUND TASK COMPLETED]\` notifications as each task finishes, and \`[ALL BACKGROUND TASKS COMPLETE]\` when all are done. Use \`agentuity_session_dashboard\` for a unified progress view.`;
390
386
  }
391
387
 
392
388
  // 5. Build SQLite dashboard section
@@ -0,0 +1,81 @@
1
+ import type { PluginInput } from '@opencode-ai/plugin';
2
+ import type { CoderConfig } from '../../types';
3
+
4
+ export interface CompletionHooks {
5
+ onParams: (input: unknown) => void;
6
+ onMessage: (input: unknown) => void;
7
+ }
8
+
9
+ /**
10
+ * Creates hooks for logging agent completion metrics.
11
+ *
12
+ * Tracks the start of each LLM call (via chat.params) and logs
13
+ * agent name, model, and duration when the response arrives (via chat.message).
14
+ */
15
+ export function createCompletionHooks(ctx: PluginInput, _config: CoderConfig): CompletionHooks {
16
+ const startTimes = new Map<string, { startedAt: number; agent?: string; model?: string }>();
17
+
18
+ return {
19
+ onParams(input: unknown): void {
20
+ // OpenCode passes agent and model as structured objects (not plain strings)
21
+ // in the chat.params hook. Normalize to string here so template literals
22
+ // don't produce '[object Object]' in the completion log.
23
+ const inp = input as {
24
+ sessionID?: string;
25
+ agent?: unknown;
26
+ model?: unknown;
27
+ };
28
+ if (!inp.sessionID) return;
29
+
30
+ const agentStr =
31
+ typeof inp.agent === 'string'
32
+ ? inp.agent
33
+ : ((inp.agent as { id?: string; name?: string; displayName?: string } | null)
34
+ ?.displayName ??
35
+ (inp.agent as { id?: string; name?: string } | null)?.name ??
36
+ (inp.agent as { id?: string } | null)?.id ??
37
+ String(inp.agent ?? 'unknown'));
38
+
39
+ const modelStr =
40
+ typeof inp.model === 'string'
41
+ ? inp.model
42
+ : ((inp.model as { id?: string; name?: string } | null)?.id ??
43
+ (inp.model as { name?: string } | null)?.name ??
44
+ String(inp.model ?? 'unknown'));
45
+
46
+ startTimes.set(inp.sessionID, {
47
+ startedAt: Date.now(),
48
+ agent: agentStr,
49
+ model: modelStr,
50
+ });
51
+ },
52
+
53
+ onMessage(input: unknown): void {
54
+ const inp = input as { sessionID?: string };
55
+ if (!inp.sessionID) return;
56
+
57
+ const start = startTimes.get(inp.sessionID);
58
+ if (!start) return;
59
+
60
+ const durationMs = Date.now() - start.startedAt;
61
+ const durationSec = (durationMs / 1000).toFixed(1);
62
+
63
+ const logLine = `Completion: agent=${start.agent} model=${start.model} duration=${durationSec}s`;
64
+
65
+ // Verbose local logging for immediate visibility
66
+ console.debug(`[agentuity-coder] ${logLine}`);
67
+
68
+ // Also send to the OpenCode log service
69
+ ctx.client.app.log({
70
+ body: {
71
+ service: 'agentuity-coder',
72
+ level: 'debug',
73
+ message: logLine,
74
+ },
75
+ });
76
+
77
+ // Clean up after logging
78
+ startTimes.delete(inp.sessionID);
79
+ },
80
+ };
81
+ }
@@ -1,5 +1,5 @@
1
1
  import type { PluginInput } from '@opencode-ai/plugin';
2
- import type { CoderConfig } from '../../types';
2
+ import type { AgentConfig, CoderConfig } from '../../types';
3
3
 
4
4
  export interface ParamsHooks {
5
5
  onParams: (input: unknown, output: unknown) => Promise<void>;
@@ -199,3 +199,99 @@ export function createParamsHooks(
199
199
  *
200
200
  * Note: Triggers use multi-word phrases to avoid false positives from common words.
201
201
  */
202
+
203
+ // ─────────────────────────────────────────────────────────────────────────────
204
+ // Model Fallback Chain
205
+ // ─────────────────────────────────────────────────────────────────────────────
206
+
207
+ /** Retryable HTTP status codes that should trigger model fallback */
208
+ export const RETRYABLE_STATUS_CODES = [429, 500, 502, 503] as const;
209
+
210
+ /**
211
+ * Tracks API errors per agent to enable model fallback on subsequent calls.
212
+ *
213
+ * When an agent's primary model fails with a retryable error (429, 500, 502, 503),
214
+ * the next `chat.params` call can select a fallback model from the agent's
215
+ * `fallbackModels` list.
216
+ *
217
+ * Current limitation: The `chat.params` hook can modify temperature/topP/topK/options
218
+ * but CANNOT change the model itself (model is in the input, not output). Full model
219
+ * fallback requires one of:
220
+ * 1. A `chat.error` hook that allows retrying with a different model
221
+ * 2. A `chat.model` hook that allows overriding the model selection
222
+ * 3. Adding `model` to the `chat.params` output type
223
+ *
224
+ * TODO: When OpenCode adds a suitable hook, implement the retry logic here:
225
+ * - On API error (429/5xx), record the failure in `agentErrorState`
226
+ * - On next `chat.params` call for the same agent, select next fallback model
227
+ * - Log: `[ModelFallback] Switching from ${currentModel} to ${fallbackModel} due to ${error}`
228
+ * - Reset fallback state after successful completion or after TTL expires
229
+ */
230
+ export class ModelFallbackTracker {
231
+ /**
232
+ * Map of agent name → { failedModel, failedAt, errorCode, fallbackIndex }
233
+ * Used to track which agents have experienced API errors.
234
+ */
235
+ private agentErrorState = new Map<
236
+ string,
237
+ {
238
+ failedModel: string;
239
+ failedAt: number;
240
+ errorCode: number;
241
+ fallbackIndex: number;
242
+ }
243
+ >();
244
+
245
+ /** TTL for error state — reset after 5 minutes */
246
+ private readonly ERROR_STATE_TTL_MS = 5 * 60 * 1000;
247
+
248
+ /**
249
+ * Record an API error for an agent. Call this from an event handler
250
+ * when a retryable API error is detected.
251
+ */
252
+ recordError(agentName: string, model: string, errorCode: number): void {
253
+ const existing = this.agentErrorState.get(agentName);
254
+ const fallbackIndex = existing ? existing.fallbackIndex + 1 : 0;
255
+ this.agentErrorState.set(agentName, {
256
+ failedModel: model,
257
+ failedAt: Date.now(),
258
+ errorCode,
259
+ fallbackIndex,
260
+ });
261
+ console.debug(
262
+ `[ModelFallback] Recorded error for ${agentName}: model=${model} code=${errorCode} fallbackIndex=${fallbackIndex}`
263
+ );
264
+ }
265
+
266
+ /**
267
+ * Get the next fallback model for an agent, if one is available.
268
+ * Returns undefined if no fallback is needed or available.
269
+ */
270
+ getNextFallback(agentName: string, agentConfig: AgentConfig): string | undefined {
271
+ const state = this.agentErrorState.get(agentName);
272
+ if (!state) return undefined;
273
+
274
+ // Check TTL
275
+ if (Date.now() - state.failedAt > this.ERROR_STATE_TTL_MS) {
276
+ this.agentErrorState.delete(agentName);
277
+ return undefined;
278
+ }
279
+
280
+ const fallbacks = agentConfig.fallbackModels;
281
+ if (!fallbacks?.length) return undefined;
282
+
283
+ if (state.fallbackIndex >= fallbacks.length) {
284
+ // Exhausted all fallbacks
285
+ return undefined;
286
+ }
287
+
288
+ return fallbacks[state.fallbackIndex];
289
+ }
290
+
291
+ /**
292
+ * Clear error state for an agent (e.g., after successful completion).
293
+ */
294
+ clearError(agentName: string): void {
295
+ this.agentErrorState.delete(agentName);
296
+ }
297
+ }
@@ -51,6 +51,10 @@ export function createSessionMemoryHooks(
51
51
  /**
52
52
  * Listen for session.compacted event.
53
53
  * The compaction summary is already in context - just tell Lead to save it.
54
+ *
55
+ * Note: Compaction continues in the SAME session (via session.prompt with
56
+ * the existing sessionId), so permissions configured in the config hook
57
+ * (plugin.ts) are automatically inherited — no re-application needed.
54
58
  */
55
59
  async onEvent(input: {
56
60
  event: { type: string; properties?: Record<string, unknown> };
@@ -165,7 +165,38 @@ export function createToolHooks(ctx: PluginInput, config: CoderConfig): ToolHook
165
165
  }
166
166
  },
167
167
 
168
- async after(_input: unknown, _output: unknown): Promise<void> {},
168
+ async after(input: unknown, output: unknown): Promise<void> {
169
+ // Graceful handling for unavailable tools: if a tool execution produced an
170
+ // error indicating the tool doesn't exist or is unavailable, normalize the
171
+ // output to a helpful message so the session continues instead of crashing.
172
+ const toolName = extractToolName(input);
173
+ if (!toolName) return;
174
+
175
+ const out = output as {
176
+ output?: string;
177
+ title?: string;
178
+ metadata?: Record<string, unknown>;
179
+ };
180
+ if (typeof out.output !== 'string') return;
181
+
182
+ const lower = out.output.toLowerCase();
183
+ const isToolMissing =
184
+ (lower.includes('not found') ||
185
+ lower.includes('not available') ||
186
+ lower.includes('does not exist') ||
187
+ lower.includes('unknown tool') ||
188
+ lower.includes('no such tool')) &&
189
+ (lower.includes('tool') || lower.includes(toolName.toLowerCase()));
190
+
191
+ if (isToolMissing) {
192
+ out.output = JSON.stringify({
193
+ error: `Tool '${toolName}' is not available in this session. It may have been removed or is not installed. Please use an alternative approach or ask the user for guidance.`,
194
+ tool: toolName,
195
+ recoverable: true,
196
+ });
197
+ out.title = `Tool unavailable: ${toolName}`;
198
+ }
199
+ },
169
200
  };
170
201
  }
171
202