mcp-subagents-opencode 1.0.0

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 (219) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +602 -0
  3. package/build/config/timeouts.d.ts +9 -0
  4. package/build/config/timeouts.d.ts.map +1 -0
  5. package/build/config/timeouts.js +18 -0
  6. package/build/config/timeouts.js.map +1 -0
  7. package/build/helpers.d.ts +6 -0
  8. package/build/helpers.d.ts.map +1 -0
  9. package/build/helpers.js +47 -0
  10. package/build/helpers.js.map +1 -0
  11. package/build/index.d.ts +3 -0
  12. package/build/index.d.ts.map +1 -0
  13. package/build/index.js +245 -0
  14. package/build/index.js.map +1 -0
  15. package/build/models.d.ts +32 -0
  16. package/build/models.d.ts.map +1 -0
  17. package/build/models.js +58 -0
  18. package/build/models.js.map +1 -0
  19. package/build/server/register-notifications.d.ts +3 -0
  20. package/build/server/register-notifications.d.ts.map +1 -0
  21. package/build/server/register-notifications.js +77 -0
  22. package/build/server/register-notifications.js.map +1 -0
  23. package/build/server/register-resources.d.ts +3 -0
  24. package/build/server/register-resources.d.ts.map +1 -0
  25. package/build/server/register-resources.js +210 -0
  26. package/build/server/register-resources.js.map +1 -0
  27. package/build/server/register-retry-execution.d.ts +2 -0
  28. package/build/server/register-retry-execution.d.ts.map +1 -0
  29. package/build/server/register-retry-execution.js +28 -0
  30. package/build/server/register-retry-execution.js.map +1 -0
  31. package/build/server/register-tasks.d.ts +3 -0
  32. package/build/server/register-tasks.d.ts.map +1 -0
  33. package/build/server/register-tasks.js +52 -0
  34. package/build/server/register-tasks.js.map +1 -0
  35. package/build/server/register-tools.d.ts +3 -0
  36. package/build/server/register-tools.d.ts.map +1 -0
  37. package/build/server/register-tools.js +32 -0
  38. package/build/server/register-tools.js.map +1 -0
  39. package/build/server/resource-helpers.d.ts +21 -0
  40. package/build/server/resource-helpers.d.ts.map +1 -0
  41. package/build/server/resource-helpers.js +84 -0
  42. package/build/server/resource-helpers.js.map +1 -0
  43. package/build/services/account-manager.d.ts +88 -0
  44. package/build/services/account-manager.d.ts.map +1 -0
  45. package/build/services/account-manager.js +239 -0
  46. package/build/services/account-manager.js.map +1 -0
  47. package/build/services/claude-code-runner.d.ts +15 -0
  48. package/build/services/claude-code-runner.d.ts.map +1 -0
  49. package/build/services/claude-code-runner.js +475 -0
  50. package/build/services/claude-code-runner.js.map +1 -0
  51. package/build/services/client-context.d.ts +31 -0
  52. package/build/services/client-context.d.ts.map +1 -0
  53. package/build/services/client-context.js +44 -0
  54. package/build/services/client-context.js.map +1 -0
  55. package/build/services/exhaustion-fallback.d.ts +27 -0
  56. package/build/services/exhaustion-fallback.d.ts.map +1 -0
  57. package/build/services/exhaustion-fallback.js +30 -0
  58. package/build/services/exhaustion-fallback.js.map +1 -0
  59. package/build/services/fallback-orchestrator.d.ts +16 -0
  60. package/build/services/fallback-orchestrator.d.ts.map +1 -0
  61. package/build/services/fallback-orchestrator.js +48 -0
  62. package/build/services/fallback-orchestrator.js.map +1 -0
  63. package/build/services/opencode-client.d.ts +40 -0
  64. package/build/services/opencode-client.d.ts.map +1 -0
  65. package/build/services/opencode-client.js +147 -0
  66. package/build/services/opencode-client.js.map +1 -0
  67. package/build/services/opencode-spawner.d.ts +56 -0
  68. package/build/services/opencode-spawner.d.ts.map +1 -0
  69. package/build/services/opencode-spawner.js +426 -0
  70. package/build/services/opencode-spawner.js.map +1 -0
  71. package/build/services/output-file.d.ts +24 -0
  72. package/build/services/output-file.d.ts.map +1 -0
  73. package/build/services/output-file.js +90 -0
  74. package/build/services/output-file.js.map +1 -0
  75. package/build/services/progress-registry.d.ts +12 -0
  76. package/build/services/progress-registry.d.ts.map +1 -0
  77. package/build/services/progress-registry.js +97 -0
  78. package/build/services/progress-registry.js.map +1 -0
  79. package/build/services/question-registry.d.ts +79 -0
  80. package/build/services/question-registry.d.ts.map +1 -0
  81. package/build/services/question-registry.js +249 -0
  82. package/build/services/question-registry.js.map +1 -0
  83. package/build/services/retry-queue.d.ts +41 -0
  84. package/build/services/retry-queue.d.ts.map +1 -0
  85. package/build/services/retry-queue.js +195 -0
  86. package/build/services/retry-queue.js.map +1 -0
  87. package/build/services/sdk-client-manager.d.ts +149 -0
  88. package/build/services/sdk-client-manager.d.ts.map +1 -0
  89. package/build/services/sdk-client-manager.js +632 -0
  90. package/build/services/sdk-client-manager.js.map +1 -0
  91. package/build/services/sdk-session-adapter.d.ts +203 -0
  92. package/build/services/sdk-session-adapter.d.ts.map +1 -0
  93. package/build/services/sdk-session-adapter.js +1088 -0
  94. package/build/services/sdk-session-adapter.js.map +1 -0
  95. package/build/services/sdk-spawner.d.ts +42 -0
  96. package/build/services/sdk-spawner.d.ts.map +1 -0
  97. package/build/services/sdk-spawner.js +488 -0
  98. package/build/services/sdk-spawner.js.map +1 -0
  99. package/build/services/session-hooks.d.ts +24 -0
  100. package/build/services/session-hooks.d.ts.map +1 -0
  101. package/build/services/session-hooks.js +130 -0
  102. package/build/services/session-hooks.js.map +1 -0
  103. package/build/services/session-snapshot.d.ts +19 -0
  104. package/build/services/session-snapshot.d.ts.map +1 -0
  105. package/build/services/session-snapshot.js +203 -0
  106. package/build/services/session-snapshot.js.map +1 -0
  107. package/build/services/subscription-registry.d.ts +12 -0
  108. package/build/services/subscription-registry.d.ts.map +1 -0
  109. package/build/services/subscription-registry.js +27 -0
  110. package/build/services/subscription-registry.js.map +1 -0
  111. package/build/services/task-manager.d.ts +150 -0
  112. package/build/services/task-manager.d.ts.map +1 -0
  113. package/build/services/task-manager.js +765 -0
  114. package/build/services/task-manager.js.map +1 -0
  115. package/build/services/task-persistence.d.ts +29 -0
  116. package/build/services/task-persistence.d.ts.map +1 -0
  117. package/build/services/task-persistence.js +159 -0
  118. package/build/services/task-persistence.js.map +1 -0
  119. package/build/services/task-status-mapper.d.ts +21 -0
  120. package/build/services/task-status-mapper.d.ts.map +1 -0
  121. package/build/services/task-status-mapper.js +171 -0
  122. package/build/services/task-status-mapper.js.map +1 -0
  123. package/build/templates/index.d.ts +22 -0
  124. package/build/templates/index.d.ts.map +1 -0
  125. package/build/templates/index.js +147 -0
  126. package/build/templates/index.js.map +1 -0
  127. package/build/templates/overlays/coder-csharp.mdx +58 -0
  128. package/build/templates/overlays/coder-go.mdx +53 -0
  129. package/build/templates/overlays/coder-java.mdx +54 -0
  130. package/build/templates/overlays/coder-kotlin.mdx +56 -0
  131. package/build/templates/overlays/coder-nextjs.mdx +65 -0
  132. package/build/templates/overlays/coder-python.mdx +53 -0
  133. package/build/templates/overlays/coder-react.mdx +55 -0
  134. package/build/templates/overlays/coder-ruby.mdx +59 -0
  135. package/build/templates/overlays/coder-rust.mdx +48 -0
  136. package/build/templates/overlays/coder-supabase.mdx +268 -0
  137. package/build/templates/overlays/coder-supastarter.mdx +313 -0
  138. package/build/templates/overlays/coder-swift.mdx +56 -0
  139. package/build/templates/overlays/coder-tauri.mdx +566 -0
  140. package/build/templates/overlays/coder-triggerdev.mdx +296 -0
  141. package/build/templates/overlays/coder-typescript.mdx +45 -0
  142. package/build/templates/overlays/coder-vue.mdx +62 -0
  143. package/build/templates/overlays/planner-architecture.mdx +78 -0
  144. package/build/templates/overlays/planner-bugfix.mdx +36 -0
  145. package/build/templates/overlays/planner-feature.mdx +38 -0
  146. package/build/templates/overlays/planner-migration.mdx +50 -0
  147. package/build/templates/overlays/planner-refactor.mdx +57 -0
  148. package/build/templates/overlays/researcher-library.mdx +59 -0
  149. package/build/templates/overlays/researcher-performance.mdx +68 -0
  150. package/build/templates/overlays/researcher-security.mdx +86 -0
  151. package/build/templates/overlays/tester-graphql.mdx +191 -0
  152. package/build/templates/overlays/tester-playwright.mdx +621 -0
  153. package/build/templates/overlays/tester-rest.mdx +101 -0
  154. package/build/templates/overlays/tester-suite.mdx +177 -0
  155. package/build/templates/super-coder.mdx +529 -0
  156. package/build/templates/super-planner.mdx +568 -0
  157. package/build/templates/super-researcher.mdx +406 -0
  158. package/build/templates/super-tester.mdx +243 -0
  159. package/build/tools/answer-question.d.ts +30 -0
  160. package/build/tools/answer-question.d.ts.map +1 -0
  161. package/build/tools/answer-question.js +108 -0
  162. package/build/tools/answer-question.js.map +1 -0
  163. package/build/tools/cancel-task.d.ts +44 -0
  164. package/build/tools/cancel-task.d.ts.map +1 -0
  165. package/build/tools/cancel-task.js +144 -0
  166. package/build/tools/cancel-task.js.map +1 -0
  167. package/build/tools/send-message.d.ts +39 -0
  168. package/build/tools/send-message.d.ts.map +1 -0
  169. package/build/tools/send-message.js +124 -0
  170. package/build/tools/send-message.js.map +1 -0
  171. package/build/tools/shared-spawn.d.ts +56 -0
  172. package/build/tools/shared-spawn.d.ts.map +1 -0
  173. package/build/tools/shared-spawn.js +114 -0
  174. package/build/tools/shared-spawn.js.map +1 -0
  175. package/build/tools/spawn-agent.d.ts +85 -0
  176. package/build/tools/spawn-agent.d.ts.map +1 -0
  177. package/build/tools/spawn-agent.js +133 -0
  178. package/build/tools/spawn-agent.js.map +1 -0
  179. package/build/tools/spawn-coder.d.ts +70 -0
  180. package/build/tools/spawn-coder.d.ts.map +1 -0
  181. package/build/tools/spawn-coder.js +71 -0
  182. package/build/tools/spawn-coder.js.map +1 -0
  183. package/build/tools/spawn-planner.d.ts +70 -0
  184. package/build/tools/spawn-planner.d.ts.map +1 -0
  185. package/build/tools/spawn-planner.js +71 -0
  186. package/build/tools/spawn-planner.js.map +1 -0
  187. package/build/tools/spawn-researcher.d.ts +70 -0
  188. package/build/tools/spawn-researcher.d.ts.map +1 -0
  189. package/build/tools/spawn-researcher.js +70 -0
  190. package/build/tools/spawn-researcher.js.map +1 -0
  191. package/build/tools/spawn-task.d.ts +74 -0
  192. package/build/tools/spawn-task.d.ts.map +1 -0
  193. package/build/tools/spawn-task.js +107 -0
  194. package/build/tools/spawn-task.js.map +1 -0
  195. package/build/tools/spawn-tester.d.ts +70 -0
  196. package/build/tools/spawn-tester.d.ts.map +1 -0
  197. package/build/tools/spawn-tester.js +69 -0
  198. package/build/tools/spawn-tester.js.map +1 -0
  199. package/build/types.d.ts +101 -0
  200. package/build/types.d.ts.map +1 -0
  201. package/build/types.js +28 -0
  202. package/build/types.js.map +1 -0
  203. package/build/utils/brief-validator.d.ts +30 -0
  204. package/build/utils/brief-validator.d.ts.map +1 -0
  205. package/build/utils/brief-validator.js +254 -0
  206. package/build/utils/brief-validator.js.map +1 -0
  207. package/build/utils/format.d.ts +34 -0
  208. package/build/utils/format.d.ts.map +1 -0
  209. package/build/utils/format.js +55 -0
  210. package/build/utils/format.js.map +1 -0
  211. package/build/utils/sanitize.d.ts +240 -0
  212. package/build/utils/sanitize.d.ts.map +1 -0
  213. package/build/utils/sanitize.js +89 -0
  214. package/build/utils/sanitize.js.map +1 -0
  215. package/build/utils/task-id-generator.d.ts +10 -0
  216. package/build/utils/task-id-generator.d.ts.map +1 -0
  217. package/build/utils/task-id-generator.js +22 -0
  218. package/build/utils/task-id-generator.js.map +1 -0
  219. package/package.json +62 -0
@@ -0,0 +1,1088 @@
1
+ /**
2
+ * SDK Session Adapter - Bridges Copilot SDK sessions to the MCP server's TaskState model.
3
+ *
4
+ * This adapter:
5
+ * - Maps SDK session events to TaskState updates using SDK's native types
6
+ * - Handles mid-session rate limit detection via session.error events
7
+ * - Triggers account rotation and session resume on rate limits
8
+ * - Manages streaming output accumulation
9
+ * - Provides unified error handling with proper typing
10
+ * - Collects completion metrics from session.shutdown
11
+ * - Tracks quota info from assistant.usage
12
+ * - Monitors tool execution and subagent activity
13
+ */
14
+ import { taskManager } from './task-manager.js';
15
+ import { sdkClientManager } from './sdk-client-manager.js';
16
+ import { TaskStatus, isTerminalStatus, ROTATABLE_STATUS_CODES, RATE_LIMIT_STATUS_CODE, } from '../types.js';
17
+ import { shouldFallbackToClaudeCode, isFallbackEnabled } from './exhaustion-fallback.js';
18
+ import { triggerClaudeFallback } from './fallback-orchestrator.js';
19
+ // String-based rate limit detection fallback
20
+ const RATE_LIMIT_STRING = "Sorry, you've hit a rate limit that restricts the number of Copilot model requests";
21
+ // Health check model for testing account availability
22
+ const HEALTH_CHECK_MODEL = 'claude-haiku-4.5';
23
+ class SDKSessionAdapter {
24
+ bindings = new Map();
25
+ rotationCallback;
26
+ /**
27
+ * Set the callback for rotation requests.
28
+ * This is called when mid-session rate limits are detected.
29
+ */
30
+ onRotationRequest(callback) {
31
+ this.rotationCallback = callback;
32
+ }
33
+ /**
34
+ * Perform a health check on the current account by creating a test session.
35
+ * Uses claude-haiku-4.5 for fast/cheap verification.
36
+ * Returns true if the account can successfully respond.
37
+ */
38
+ async performHealthCheck(cwd) {
39
+ const strictProbe = process.env.COPILOT_STRICT_HEALTH_CHECK === 'true';
40
+ const authStatus = await sdkClientManager.checkAuthStatus(cwd);
41
+ if (!authStatus.isAuthenticated) {
42
+ console.error(`[sdk-session-adapter] Health check failed: account is not authenticated`);
43
+ return false;
44
+ }
45
+ if (!strictProbe) {
46
+ console.error(`[sdk-session-adapter] Health check passed (auth status)`);
47
+ return true;
48
+ }
49
+ const healthCheckSessionId = `health-check-${Date.now()}`;
50
+ try {
51
+ console.error(`[sdk-session-adapter] Health check: strict probe with ${HEALTH_CHECK_MODEL}...`);
52
+ const testSession = await sdkClientManager.createSession(cwd, healthCheckSessionId, { model: HEALTH_CHECK_MODEL });
53
+ // Send a simple test message
54
+ await testSession.sendAndWait({ prompt: 'hi' });
55
+ console.error(`[sdk-session-adapter] Health check passed`);
56
+ return true;
57
+ }
58
+ catch (err) {
59
+ console.error(`[sdk-session-adapter] Health check failed:`, err);
60
+ return false;
61
+ }
62
+ finally {
63
+ // Always clean up the health check session, whether it succeeded or failed
64
+ await sdkClientManager.destroySession(healthCheckSessionId).catch(() => { });
65
+ }
66
+ }
67
+ /**
68
+ * Bind a SDK session to a task, setting up event handlers.
69
+ */
70
+ bind(taskId, session, pendingPrompt) {
71
+ // Clean up any existing binding
72
+ this.unbind(taskId);
73
+ const startTime = new Date();
74
+ const binding = {
75
+ taskId,
76
+ session,
77
+ sessionId: session.sessionId,
78
+ unsubscribe: () => { },
79
+ outputBuffer: [],
80
+ reasoningBuffer: [],
81
+ startTime,
82
+ isCompleted: false,
83
+ isPaused: false,
84
+ rotationAttempts: 0,
85
+ maxRotationAttempts: 10,
86
+ rotationInProgress: false,
87
+ isUnbound: false,
88
+ pendingPrompt,
89
+ // Initialize metrics tracking
90
+ turnCount: 0,
91
+ totalTokens: { input: 0, output: 0 },
92
+ toolMetrics: new Map(),
93
+ toolStartTimes: new Map(),
94
+ toolCallIdToName: new Map(),
95
+ activeSubagents: new Map(),
96
+ completedSubagents: [],
97
+ quotas: new Map(),
98
+ };
99
+ // Subscribe to all session events using SDK's typed event system
100
+ const unsubscribe = session.on((event) => {
101
+ this.handleEvent(taskId, event, binding).catch((err) => {
102
+ console.error(`[sdk-session-adapter] Error handling event ${event.type} for task ${taskId}:`, err);
103
+ });
104
+ });
105
+ binding.unsubscribe = unsubscribe;
106
+ this.bindings.set(taskId, binding);
107
+ // Initialize session metrics in task
108
+ taskManager.updateTask(taskId, {
109
+ status: TaskStatus.RUNNING,
110
+ sessionId: session.sessionId,
111
+ session,
112
+ sessionMetrics: {
113
+ quotas: {},
114
+ toolMetrics: {},
115
+ activeSubagents: [],
116
+ completedSubagents: [],
117
+ turnCount: 0,
118
+ totalTokens: { input: 0, output: 0 },
119
+ },
120
+ });
121
+ console.error(`[sdk-session-adapter] Bound session ${session.sessionId} to task ${taskId}`);
122
+ }
123
+ /**
124
+ * Handle SDK session events and map them to task updates.
125
+ * Uses type narrowing for type-safe event handling.
126
+ */
127
+ async handleEvent(taskId, event, binding) {
128
+ // Guard: skip events if binding was already unbound (race from queued events)
129
+ if (binding.isUnbound) {
130
+ return;
131
+ }
132
+ const task = taskManager.getTask(taskId);
133
+ if (!task) {
134
+ console.error(`[sdk-session-adapter] Task ${taskId} not found for event ${event.type}`);
135
+ return;
136
+ }
137
+ // Skip events if session is paused (during rotation)
138
+ if (binding.isPaused && event.type !== 'session.error') {
139
+ return;
140
+ }
141
+ switch (event.type) {
142
+ case 'session.start':
143
+ this.handleSessionStart(taskId, event);
144
+ break;
145
+ case 'session.resume':
146
+ taskManager.appendOutput(taskId, `[session] Resumed at ${event.data.resumeTime}`);
147
+ binding.isPaused = false; // Clear pause state on resume
148
+ break;
149
+ case 'session.idle':
150
+ this.handleSessionIdle(taskId, event, binding);
151
+ break;
152
+ case 'session.error':
153
+ await this.handleSessionError(taskId, event, binding);
154
+ break;
155
+ case 'assistant.turn_start':
156
+ this.handleTurnStart(taskId, event, binding);
157
+ break;
158
+ case 'assistant.message_delta':
159
+ this.handleMessageDelta(taskId, event, binding);
160
+ break;
161
+ case 'assistant.message':
162
+ await this.handleAssistantMessage(taskId, event, binding);
163
+ break;
164
+ case 'assistant.reasoning': {
165
+ const reasoning = event.data.content || binding.reasoningBuffer.join('');
166
+ if (reasoning) {
167
+ // Write reasoning to file only — not to in-memory output (saves tokens for caller)
168
+ taskManager.appendOutputFileOnly(taskId, `[reasoning] ${reasoning}`);
169
+ }
170
+ binding.reasoningBuffer.length = 0;
171
+ break;
172
+ }
173
+ case 'assistant.reasoning_delta':
174
+ binding.reasoningBuffer.push(event.data.deltaContent);
175
+ break;
176
+ case 'assistant.turn_end': {
177
+ if (binding.reasoningBuffer.length) {
178
+ // Reasoning → file only (verbose debug, not for caller tokens)
179
+ taskManager.appendOutputFileOnly(taskId, `[reasoning] ${binding.reasoningBuffer.join('')}`);
180
+ binding.reasoningBuffer.length = 0;
181
+ }
182
+ if (binding.outputBuffer.length) {
183
+ taskManager.appendOutput(taskId, binding.outputBuffer.join(''));
184
+ binding.outputBuffer.length = 0;
185
+ }
186
+ // Turn ended marker → file only (Turn started is sufficient for caller)
187
+ taskManager.appendOutputFileOnly(taskId, `[assistant] Turn ended: ${event.data.turnId}`);
188
+ break;
189
+ }
190
+ case 'assistant.usage':
191
+ this.handleUsage(taskId, event, binding);
192
+ break;
193
+ case 'tool.execution_start':
194
+ this.handleToolStart(taskId, event, binding);
195
+ break;
196
+ case 'tool.execution_progress':
197
+ taskManager.appendOutput(taskId, `[tool] Progress: ${event.data.progressMessage}`);
198
+ break;
199
+ case 'tool.execution_complete':
200
+ this.handleToolComplete(taskId, event, binding);
201
+ break;
202
+ case 'subagent.started':
203
+ this.handleSubagentStarted(taskId, event, binding);
204
+ break;
205
+ case 'subagent.completed':
206
+ this.handleSubagentCompleted(taskId, event, binding);
207
+ break;
208
+ case 'subagent.failed':
209
+ this.handleSubagentFailed(taskId, event, binding);
210
+ break;
211
+ case 'session.compaction_start':
212
+ taskManager.appendOutput(taskId, `[session] Context compaction started`);
213
+ break;
214
+ case 'session.compaction_complete':
215
+ if (event.data.success) {
216
+ taskManager.appendOutput(taskId, `[session] Compaction complete: removed ${event.data.tokensRemoved} tokens`);
217
+ }
218
+ else {
219
+ taskManager.appendOutput(taskId, `[session] Compaction failed: ${event.data.error}`);
220
+ }
221
+ break;
222
+ case 'session.shutdown':
223
+ this.handleSessionShutdown(taskId, event, binding);
224
+ break;
225
+ case 'abort':
226
+ this.handleAbort(taskId, event, binding);
227
+ break;
228
+ case 'user.message':
229
+ // User message → file only (caller already knows what it sent)
230
+ taskManager.appendOutputFileOnly(taskId, `[user] ${event.data.content.length > 100 ? event.data.content.slice(0, 100) + '...' : event.data.content}`);
231
+ break;
232
+ default:
233
+ // Log other events in debug mode
234
+ if (process.env.DEBUG_SDK_EVENTS === 'true') {
235
+ console.error(`[sdk-session-adapter] Event: ${event.type}`);
236
+ }
237
+ }
238
+ }
239
+ /**
240
+ * Handle session.start event
241
+ */
242
+ handleSessionStart(taskId, event) {
243
+ // Session setup details → file only (internal metadata, not useful to caller)
244
+ taskManager.appendOutputFileOnly(taskId, `[session] Started: ${event.data.sessionId}`);
245
+ if (event.data.selectedModel) {
246
+ taskManager.appendOutputFileOnly(taskId, `[session] Model: ${event.data.selectedModel}`);
247
+ }
248
+ if (event.data.context?.cwd) {
249
+ taskManager.appendOutputFileOnly(taskId, `[session] CWD: ${event.data.context.cwd}`);
250
+ }
251
+ }
252
+ /**
253
+ * Handle assistant.turn_start event - track turn count
254
+ */
255
+ handleTurnStart(taskId, event, binding) {
256
+ binding.turnCount++;
257
+ taskManager.appendOutput(taskId, `--- Turn ${binding.turnCount} ---`);
258
+ // Update session metrics
259
+ this.updateSessionMetrics(taskId, binding);
260
+ }
261
+ /**
262
+ * Handle session.idle event - indicates completion
263
+ */
264
+ handleSessionIdle(taskId, _event, binding) {
265
+ if (!binding.isCompleted) {
266
+ binding.isCompleted = true;
267
+ // Finalize session metrics
268
+ this.updateSessionMetrics(taskId, binding);
269
+ // Emit compact summary line (replaces verbose per-turn [usage]/[quota])
270
+ const totalTokens = binding.totalTokens.input + binding.totalTokens.output;
271
+ const elapsed = Date.now() - binding.startTime.getTime();
272
+ const toolCount = Array.from(binding.toolMetrics.values()).reduce((s, m) => s + m.executionCount, 0);
273
+ taskManager.appendOutput(taskId, `[summary] ${binding.turnCount} turns | ${toolCount} tool calls | ${Math.round(totalTokens / 1000)}K tokens | ${Math.round(elapsed / 1000)}s`);
274
+ taskManager.updateTask(taskId, {
275
+ status: TaskStatus.COMPLETED,
276
+ endTime: new Date().toISOString(),
277
+ exitCode: 0,
278
+ session: undefined,
279
+ });
280
+ // Destroy session to release PTY FDs
281
+ this.unbind(taskId);
282
+ console.error(`[sdk-session-adapter] Task ${taskId} completed (session.idle)`);
283
+ }
284
+ }
285
+ /**
286
+ * Handle session.error event - key for mid-session rate limit detection
287
+ * Now stores structured failure context
288
+ */
289
+ async handleSessionError(taskId, event, binding) {
290
+ // Flush any buffered output before handling the error
291
+ if (binding.outputBuffer.length) {
292
+ taskManager.appendOutput(taskId, binding.outputBuffer.join(''));
293
+ binding.outputBuffer.length = 0;
294
+ }
295
+ if (binding.reasoningBuffer.length) {
296
+ taskManager.appendOutputFileOnly(taskId, `[reasoning] ${binding.reasoningBuffer.join('')}`);
297
+ binding.reasoningBuffer.length = 0;
298
+ }
299
+ const { errorType, message, statusCode, providerCallId, stack } = event.data;
300
+ taskManager.appendOutput(taskId, `[error] ${errorType}: ${message} (status: ${statusCode || 'unknown'})`);
301
+ // Create structured failure context from SDK event
302
+ const failureContext = {
303
+ errorType,
304
+ statusCode,
305
+ providerCallId,
306
+ message,
307
+ stack,
308
+ recoverable: statusCode !== undefined && ROTATABLE_STATUS_CODES.has(statusCode),
309
+ };
310
+ // Store failure context in task immediately
311
+ taskManager.updateTask(taskId, { failureContext });
312
+ // Check if this is a rotatable error (rate limit or server error)
313
+ const isRotatableError = statusCode !== undefined && ROTATABLE_STATUS_CODES.has(statusCode);
314
+ const isRateLimit = statusCode === RATE_LIMIT_STATUS_CODE;
315
+ if (isRotatableError && binding.rotationAttempts < binding.maxRotationAttempts && !binding.rotationInProgress) {
316
+ // RC-1: Guard against concurrent rotation from multiple error events
317
+ binding.rotationInProgress = true;
318
+ binding.isPaused = true;
319
+ binding.rotationAttempts++;
320
+ taskManager.appendOutput(taskId, `[rotation] Attempting account rotation (attempt ${binding.rotationAttempts}/${binding.maxRotationAttempts}) due to ${isRateLimit ? 'rate limit' : 'server error'} ${statusCode}`);
321
+ try {
322
+ // Try to rotate and resume
323
+ const rotationSuccess = await this.attemptRotationAndResume(taskId, binding, statusCode, message);
324
+ if (rotationSuccess) {
325
+ return; // Successfully rotated and resumed, don't mark as failed
326
+ }
327
+ }
328
+ finally {
329
+ binding.rotationInProgress = false;
330
+ }
331
+ // Rotation failed - fall through to handle as error
332
+ taskManager.appendOutput(taskId, `[rotation] Rotation failed, marking task as ${isRateLimit ? 'rate_limited' : 'failed'}`);
333
+ }
334
+ else if (isRotatableError && binding.rotationInProgress) {
335
+ // RC-1: Another error arrived while rotation is already in progress — skip
336
+ taskManager.appendOutput(taskId, `[rotation] Rotation already in progress, ignoring duplicate error event`);
337
+ return;
338
+ }
339
+ // RC-6: Re-check terminal state before marking — task may have been cancelled/completed during rotation
340
+ const currentTask = taskManager.getTask(taskId);
341
+ if (!currentTask || isTerminalStatus(currentTask.status)) {
342
+ return;
343
+ }
344
+ // Handle based on error type
345
+ if (isRateLimit) {
346
+ if (isFallbackEnabled()) {
347
+ const started = await triggerClaudeFallback(taskId, {
348
+ reason: 'copilot_rate_limited',
349
+ errorMessage: message,
350
+ session: binding.session,
351
+ });
352
+ if (started) {
353
+ this.unbind(taskId);
354
+ return;
355
+ }
356
+ }
357
+ this.markAsRateLimited(taskId, binding, message, failureContext);
358
+ }
359
+ else if (isFallbackEnabled() && !isRotatableError) {
360
+ // Non-rotatable error (CLI crash, auth error, etc.) — fallback to Claude Agent SDK
361
+ console.error(`[sdk-session-adapter] Task ${taskId} hit non-rotatable error, falling back to Claude Agent SDK`);
362
+ const started = await triggerClaudeFallback(taskId, {
363
+ reason: 'copilot_non_rotatable_error',
364
+ errorMessage: message,
365
+ session: binding.session,
366
+ });
367
+ if (started) {
368
+ this.unbind(taskId);
369
+ return;
370
+ }
371
+ }
372
+ else {
373
+ this.markAsFailed(taskId, binding, message, statusCode, failureContext);
374
+ }
375
+ }
376
+ /**
377
+ * Attempt to rotate to a new account and resume the session
378
+ */
379
+ async attemptRotationAndResume(taskId, binding, statusCode, errorMessage) {
380
+ // First try the registered callback
381
+ if (this.rotationCallback) {
382
+ try {
383
+ const result = await this.rotationCallback(taskId, binding.sessionId, `status_${statusCode}`, statusCode);
384
+ // RC-2: Check terminal state after await — task may have been cancelled during rotation callback
385
+ const taskAfterCallback = taskManager.getTask(taskId);
386
+ if (!taskAfterCallback || isTerminalStatus(taskAfterCallback.status)) {
387
+ console.error(`[sdk-session-adapter] Task ${taskId} became ${taskAfterCallback?.status ?? 'deleted'} during rotation callback`);
388
+ return false;
389
+ }
390
+ if (result.rotated && result.newSession) {
391
+ // Successfully rotated - rebind with new session
392
+ await this.rebindWithNewSession(taskId, binding, result.newSession);
393
+ return true;
394
+ }
395
+ }
396
+ catch (err) {
397
+ console.error(`[sdk-session-adapter] Rotation callback failed:`, err);
398
+ }
399
+ }
400
+ // Try rotating via SDK client manager directly
401
+ const task = taskManager.getTask(taskId);
402
+ if (!task)
403
+ return false;
404
+ const taskCwd = task.cwd || process.cwd();
405
+ // RC-5: Heartbeat before long-running rotateOnError
406
+ taskManager.appendOutput(taskId, `[rotation] Rotating to next account...`);
407
+ const rotationResult = await sdkClientManager.rotateOnError(taskCwd, `status_${statusCode}`);
408
+ // RC-2: Check terminal state after rotateOnError await
409
+ const taskAfterRotate = taskManager.getTask(taskId);
410
+ if (!taskAfterRotate || isTerminalStatus(taskAfterRotate.status)) {
411
+ console.error(`[sdk-session-adapter] Task ${taskId} became ${taskAfterRotate?.status ?? 'deleted'} during rotateOnError`);
412
+ return false;
413
+ }
414
+ if (!rotationResult.success) {
415
+ if (shouldFallbackToClaudeCode(rotationResult)) {
416
+ // All accounts exhausted - fallback to Claude Agent SDK
417
+ taskManager.appendOutput(taskId, `[rotation] All accounts exhausted. Switching to Claude Agent SDK...`);
418
+ // Get task and calculate remaining timeout
419
+ const task = taskManager.getTask(taskId);
420
+ if (!task)
421
+ return false;
422
+ const started = await triggerClaudeFallback(taskId, {
423
+ reason: 'copilot_accounts_exhausted',
424
+ errorMessage: 'All Copilot accounts exhausted',
425
+ session: binding.session,
426
+ cwd: taskCwd,
427
+ });
428
+ if (started) {
429
+ // Unbind current session
430
+ this.unbind(taskId);
431
+ return true; // Indicate fallback was triggered
432
+ }
433
+ }
434
+ return false;
435
+ }
436
+ // RC-5: Heartbeat before long-running health check
437
+ taskManager.appendOutput(taskId, `[rotation] Running health check on new account...`);
438
+ const healthCheckPassed = await this.performHealthCheck(taskCwd);
439
+ // RC-2: Check terminal state after health check await
440
+ const taskAfterHealth = taskManager.getTask(taskId);
441
+ if (!taskAfterHealth || isTerminalStatus(taskAfterHealth.status)) {
442
+ console.error(`[sdk-session-adapter] Task ${taskId} became ${taskAfterHealth?.status ?? 'deleted'} during health check`);
443
+ return false;
444
+ }
445
+ if (!healthCheckPassed) {
446
+ binding.rotationAttempts++;
447
+ taskManager.appendOutput(taskId, `[rotation] Health check failed, trying next account (attempt ${binding.rotationAttempts}/${binding.maxRotationAttempts})...`);
448
+ if (binding.rotationAttempts >= binding.maxRotationAttempts) {
449
+ return false;
450
+ }
451
+ // Recursively try next account
452
+ return this.attemptRotationAndResume(taskId, binding, statusCode, errorMessage);
453
+ }
454
+ // Try to resume session with new account
455
+ try {
456
+ // RC-5: Heartbeat before long-running resumeSession
457
+ taskManager.appendOutput(taskId, `[rotation] Health check passed, resuming session ${binding.sessionId}...`);
458
+ const newSession = await sdkClientManager.resumeSession(taskCwd, binding.sessionId, {}, taskId);
459
+ // RC-2: Check terminal state after resumeSession await
460
+ const taskAfterResume = taskManager.getTask(taskId);
461
+ if (!taskAfterResume || isTerminalStatus(taskAfterResume.status)) {
462
+ console.error(`[sdk-session-adapter] Task ${taskId} became ${taskAfterResume?.status ?? 'deleted'} during resumeSession`);
463
+ // Clean up the orphaned session
464
+ await sdkClientManager.destroySession(newSession.sessionId).catch(() => { });
465
+ return false;
466
+ }
467
+ await this.rebindWithNewSession(taskId, binding, newSession);
468
+ return true;
469
+ }
470
+ catch (resumeErr) {
471
+ console.error(`[sdk-session-adapter] Failed to resume session after rotation:`, resumeErr);
472
+ return false;
473
+ }
474
+ }
475
+ /**
476
+ * Rebind a task with a new session after rotation
477
+ */
478
+ async rebindWithNewSession(taskId, oldBinding, newSession) {
479
+ // Unsubscribe from old session and destroy it to release PTY FDs (RC-3 fix)
480
+ oldBinding.unsubscribe();
481
+ sdkClientManager.destroySession(oldBinding.sessionId).catch((err) => {
482
+ console.error(`[sdk-session-adapter] Failed to destroy old session ${oldBinding.sessionId} during rebind:`, err);
483
+ });
484
+ // RC-4: Verify task is still alive before rebinding
485
+ const task = taskManager.getTask(taskId);
486
+ if (!task || isTerminalStatus(task.status)) {
487
+ console.error(`[sdk-session-adapter] Task ${taskId} is ${task?.status ?? 'deleted'}, skipping rebind`);
488
+ await sdkClientManager.destroySession(newSession.sessionId).catch(() => { });
489
+ return;
490
+ }
491
+ // Create new binding preserving state
492
+ const newBinding = {
493
+ taskId,
494
+ session: newSession,
495
+ sessionId: newSession.sessionId,
496
+ unsubscribe: () => { },
497
+ outputBuffer: oldBinding.outputBuffer,
498
+ reasoningBuffer: oldBinding.reasoningBuffer,
499
+ lastMessageId: oldBinding.lastMessageId,
500
+ startTime: oldBinding.startTime,
501
+ isCompleted: false,
502
+ isPaused: false,
503
+ rotationAttempts: oldBinding.rotationAttempts,
504
+ maxRotationAttempts: oldBinding.maxRotationAttempts,
505
+ rotationInProgress: false,
506
+ isUnbound: false,
507
+ pendingPrompt: oldBinding.pendingPrompt,
508
+ // Preserve metrics
509
+ turnCount: oldBinding.turnCount,
510
+ totalTokens: oldBinding.totalTokens,
511
+ toolMetrics: oldBinding.toolMetrics,
512
+ toolStartTimes: oldBinding.toolStartTimes,
513
+ toolCallIdToName: oldBinding.toolCallIdToName,
514
+ activeSubagents: oldBinding.activeSubagents,
515
+ completedSubagents: oldBinding.completedSubagents,
516
+ quotas: oldBinding.quotas,
517
+ };
518
+ // Subscribe to new session events
519
+ const unsubscribe = newSession.on((event) => {
520
+ this.handleEvent(taskId, event, newBinding).catch((err) => {
521
+ console.error(`[sdk-session-adapter] Error handling event ${event.type} for task ${taskId}:`, err);
522
+ });
523
+ });
524
+ newBinding.unsubscribe = unsubscribe;
525
+ this.bindings.set(taskId, newBinding);
526
+ // Update task with new session reference
527
+ taskManager.updateTask(taskId, {
528
+ sessionId: newSession.sessionId,
529
+ session: newSession,
530
+ });
531
+ taskManager.appendOutput(taskId, `[rotation] Successfully resumed with new session`);
532
+ console.error(`[sdk-session-adapter] Rebind task ${taskId} to new session ${newSession.sessionId}`);
533
+ // Send "continue" message to resume the conversation
534
+ taskManager.appendOutput(taskId, `[rotation] Sending 'continue' to resume conversation...`);
535
+ try {
536
+ await newSession.sendAndWait({ prompt: 'continue' });
537
+ }
538
+ catch (err) {
539
+ console.error(`[sdk-session-adapter] Failed to send continue message:`, err);
540
+ // Don't fail the rebind - the session is still valid, just the continue failed
541
+ }
542
+ }
543
+ /**
544
+ * Mark task as rate limited (for when rotation is exhausted)
545
+ */
546
+ markAsRateLimited(taskId, binding, message, failureContext) {
547
+ binding.isCompleted = true;
548
+ binding.rateLimitInfo = { statusCode: RATE_LIMIT_STATUS_CODE };
549
+ // Use quota reset date if available
550
+ let nextRetryTime;
551
+ const quotaInfo = Array.from(binding.quotas.values()).find(q => q.resetDate);
552
+ if (quotaInfo?.resetDate) {
553
+ nextRetryTime = quotaInfo.resetDate;
554
+ }
555
+ else {
556
+ nextRetryTime = new Date(Date.now() + 5 * 60 * 1000).toISOString();
557
+ }
558
+ const retryInfo = {
559
+ reason: message,
560
+ retryCount: 0,
561
+ nextRetryTime,
562
+ maxRetries: 6,
563
+ originalTaskId: taskId,
564
+ };
565
+ this.updateSessionMetrics(taskId, binding);
566
+ taskManager.updateTask(taskId, {
567
+ status: TaskStatus.RATE_LIMITED,
568
+ endTime: new Date().toISOString(),
569
+ error: message,
570
+ retryInfo,
571
+ failureContext,
572
+ session: undefined,
573
+ });
574
+ // Destroy session to release PTY FDs
575
+ this.unbind(taskId);
576
+ console.error(`[sdk-session-adapter] Task ${taskId} rate limited (all rotations exhausted)`);
577
+ }
578
+ /**
579
+ * Mark task as failed with structured failure context
580
+ */
581
+ markAsFailed(taskId, binding, message, statusCode, failureContext) {
582
+ if (!binding.isCompleted) {
583
+ binding.isCompleted = true;
584
+ binding.error = message;
585
+ this.updateSessionMetrics(taskId, binding);
586
+ taskManager.updateTask(taskId, {
587
+ status: TaskStatus.FAILED,
588
+ endTime: new Date().toISOString(),
589
+ error: `${message}${statusCode ? ` (status: ${statusCode})` : ''}`,
590
+ exitCode: 1,
591
+ failureContext,
592
+ session: undefined,
593
+ });
594
+ // Destroy session to release PTY FDs
595
+ this.unbind(taskId);
596
+ console.error(`[sdk-session-adapter] Task ${taskId} failed: ${message}`);
597
+ }
598
+ }
599
+ /**
600
+ * Handle assistant.message_delta event (streaming)
601
+ */
602
+ handleMessageDelta(taskId, event, binding) {
603
+ if (event.data.deltaContent) {
604
+ binding.outputBuffer.push(event.data.deltaContent);
605
+ }
606
+ binding.lastMessageId = event.data.messageId;
607
+ }
608
+ /**
609
+ * Handle assistant.message event (final message)
610
+ * Also checks for string-based rate limit detection as fallback
611
+ */
612
+ async handleAssistantMessage(taskId, event, binding) {
613
+ // Prefer the final content from the event; fall back to accumulated buffer.
614
+ // After appending, clear the buffer so assistant.turn_end won't duplicate.
615
+ const content = event.data.content || binding.outputBuffer.join('');
616
+ if (content) {
617
+ taskManager.appendOutput(taskId, content);
618
+ }
619
+ binding.outputBuffer.length = 0;
620
+ // Message complete UUID → file only (noise for caller)
621
+ taskManager.appendOutputFileOnly(taskId, `[assistant] Message complete: ${event.data.messageId}`);
622
+ binding.lastMessageId = event.data.messageId;
623
+ // String-based rate limit detection fallback
624
+ // Only check current message content to avoid loops
625
+ const rateLimitContent = event.data.content || '';
626
+ if (rateLimitContent.includes(RATE_LIMIT_STRING) && !binding.isCompleted && !binding.isPaused && !binding.rotationInProgress) {
627
+ // Check max rotation attempts first (parity with error-driven path at line 418)
628
+ if (binding.rotationAttempts >= binding.maxRotationAttempts) {
629
+ const currentTask = taskManager.getTask(taskId);
630
+ if (!currentTask || isTerminalStatus(currentTask.status)) {
631
+ return;
632
+ }
633
+ if (isFallbackEnabled()) {
634
+ const started = await triggerClaudeFallback(taskId, {
635
+ reason: 'copilot_rate_limited',
636
+ errorMessage: 'Rate limit detected in response (max rotations exhausted)',
637
+ session: binding.session,
638
+ });
639
+ if (started) {
640
+ this.unbind(taskId);
641
+ return;
642
+ }
643
+ }
644
+ this.markAsRateLimited(taskId, binding, 'Rate limit detected in response (max rotations exhausted)');
645
+ return;
646
+ }
647
+ // RC-1: Guard against concurrent rotation from string-based rate limit detection
648
+ binding.rotationInProgress = true;
649
+ binding.isPaused = true;
650
+ binding.rotationAttempts++;
651
+ taskManager.appendOutput(taskId, `[rate-limit] Detected rate limit in response, attempting rotation...`);
652
+ let rotationSuccess = false;
653
+ try {
654
+ rotationSuccess = await this.attemptRotationAndResume(taskId, binding, 429, 'Rate limit detected in response');
655
+ }
656
+ finally {
657
+ binding.rotationInProgress = false;
658
+ }
659
+ if (!rotationSuccess) {
660
+ // RC-6: Re-check terminal state before marking
661
+ const currentTask = taskManager.getTask(taskId);
662
+ if (!currentTask || isTerminalStatus(currentTask.status)) {
663
+ return;
664
+ }
665
+ if (isFallbackEnabled()) {
666
+ const started = await triggerClaudeFallback(taskId, {
667
+ reason: 'copilot_rate_limited',
668
+ errorMessage: 'Rate limit detected in response (rotation failed)',
669
+ session: binding.session,
670
+ });
671
+ if (started) {
672
+ this.unbind(taskId);
673
+ return;
674
+ }
675
+ }
676
+ this.markAsRateLimited(taskId, binding, 'Rate limit detected in response (rotation failed)');
677
+ }
678
+ }
679
+ }
680
+ /**
681
+ * Handle assistant.usage event for quota tracking
682
+ * Stores structured quota info and updates session metrics
683
+ */
684
+ handleUsage(taskId, event, binding) {
685
+ const { model, inputTokens, outputTokens, quotaSnapshots, cacheReadTokens, cacheWriteTokens, cost } = event.data;
686
+ // Update total tokens
687
+ binding.totalTokens.input += inputTokens || 0;
688
+ binding.totalTokens.output += outputTokens || 0;
689
+ // Per-turn usage → file only (cumulative summary emitted at completion)
690
+ taskManager.appendOutputFileOnly(taskId, `[usage] Model: ${model}, Input: ${inputTokens || 0}, Output: ${outputTokens || 0}${cost ? `, Cost: $${cost.toFixed(4)}` : ''}`);
691
+ // Process quota snapshots and store structured info
692
+ if (quotaSnapshots) {
693
+ for (const [tier, snapshotRaw] of Object.entries(quotaSnapshots)) {
694
+ const snapshot = snapshotRaw;
695
+ const quotaInfo = {
696
+ tier,
697
+ remainingPercentage: snapshot.remainingPercentage,
698
+ usedRequests: snapshot.usedRequests ?? 0,
699
+ entitlementRequests: snapshot.entitlementRequests ?? 0,
700
+ isUnlimited: snapshot.isUnlimitedEntitlement ?? false,
701
+ overage: snapshot.overage ?? 0,
702
+ resetDate: snapshot.resetDate,
703
+ lastUpdated: new Date().toISOString(),
704
+ };
705
+ binding.quotas.set(tier, quotaInfo);
706
+ if (snapshot.remainingPercentage <= 10) {
707
+ // Quota warning → file only (available via quotaInfo in MCP resource)
708
+ taskManager.appendOutputFileOnly(taskId, `[quota] Warning: ${tier} at ${snapshot.remainingPercentage}% remaining (resets: ${snapshot.resetDate || 'unknown'})`);
709
+ // Update task with quota warning
710
+ taskManager.updateTask(taskId, { quotaInfo });
711
+ binding.rateLimitInfo = {
712
+ statusCode: 0, // Not yet rate limited
713
+ remainingPercentage: snapshot.remainingPercentage,
714
+ resetDate: snapshot.resetDate,
715
+ };
716
+ // Proactively rotate if quota is critically low (< 1%)
717
+ // Guard: Only rotate if not already rotating (prevents race condition)
718
+ if (snapshot.remainingPercentage < 1 &&
719
+ binding.rotationAttempts < binding.maxRotationAttempts &&
720
+ !binding.rotationInProgress) {
721
+ binding.rotationInProgress = true;
722
+ binding.isPaused = true;
723
+ binding.rotationAttempts++; // Count proactive rotation toward the limit
724
+ taskManager.appendOutput(taskId, `[quota] Quota critically low, proactively rotating (attempt ${binding.rotationAttempts}/${binding.maxRotationAttempts})...`);
725
+ this.attemptRotationAndResume(taskId, binding, 429, 'Quota critically low')
726
+ .finally(() => {
727
+ binding.rotationInProgress = false;
728
+ binding.isPaused = false;
729
+ })
730
+ .catch(console.error);
731
+ }
732
+ }
733
+ }
734
+ }
735
+ // Update session metrics
736
+ this.updateSessionMetrics(taskId, binding);
737
+ }
738
+ /**
739
+ * Handle tool.execution_start event - track tool metrics
740
+ */
741
+ handleToolStart(taskId, event, binding) {
742
+ const { toolName, toolCallId, mcpServerName, mcpToolName } = event.data;
743
+ // Track start time and toolCallId → toolName mapping for accurate completion matching (METRIC-1 fix)
744
+ binding.toolStartTimes.set(toolCallId, Date.now());
745
+ binding.toolCallIdToName.set(toolCallId, toolName);
746
+ // Initialize or update tool metrics
747
+ let metrics = binding.toolMetrics.get(toolName);
748
+ if (!metrics) {
749
+ metrics = {
750
+ toolName,
751
+ mcpServer: mcpServerName,
752
+ mcpToolName,
753
+ executionCount: 0,
754
+ successCount: 0,
755
+ failureCount: 0,
756
+ totalDurationMs: 0,
757
+ };
758
+ binding.toolMetrics.set(toolName, metrics);
759
+ }
760
+ const serverInfo = mcpServerName ? ` (MCP: ${mcpServerName})` : '';
761
+ // Tool start → file only; the completion line (with duration) will appear in-memory
762
+ taskManager.appendOutputFileOnly(taskId, `[tool] Starting: ${toolName}${serverInfo}`);
763
+ }
764
+ /**
765
+ * Handle tool.execution_complete event - finalize tool metrics
766
+ */
767
+ handleToolComplete(taskId, event, binding) {
768
+ const { toolCallId, success } = event.data;
769
+ // METRIC-1 fix: Use toolCallId → toolName mapping for accurate completion matching
770
+ const startTime = binding.toolStartTimes.get(toolCallId);
771
+ const duration = startTime ? Date.now() - startTime : 0;
772
+ const toolName = binding.toolCallIdToName.get(toolCallId);
773
+ // Clean up tracking maps
774
+ binding.toolStartTimes.delete(toolCallId);
775
+ binding.toolCallIdToName.delete(toolCallId);
776
+ // Find and update metrics using the tracked toolName (deterministic matching)
777
+ const metrics = toolName ? binding.toolMetrics.get(toolName) : undefined;
778
+ if (metrics) {
779
+ metrics.executionCount++;
780
+ metrics.totalDurationMs += duration;
781
+ metrics.lastExecutedAt = new Date().toISOString();
782
+ if (success) {
783
+ metrics.successCount++;
784
+ // Trivial tools (<100ms): compact single line to in-memory, verbose to file
785
+ if (duration < 100) {
786
+ taskManager.appendOutputFileOnly(taskId, `[tool] Completed: ${metrics.toolName} (${duration}ms)`);
787
+ }
788
+ else {
789
+ taskManager.appendOutput(taskId, `[tool] ${metrics.toolName} (${duration}ms)`);
790
+ }
791
+ }
792
+ else {
793
+ metrics.failureCount++;
794
+ const errorMsg = event.data.error?.message || 'Unknown error';
795
+ taskManager.appendOutput(taskId, `[tool] Failed: ${metrics.toolName} - ${errorMsg}`);
796
+ }
797
+ }
798
+ else {
799
+ // Fallback: log completion without metrics update if toolName not found
800
+ taskManager.appendOutput(taskId, `[tool] Completed: unknown (${duration}ms, callId: ${toolCallId})`);
801
+ }
802
+ this.updateSessionMetrics(taskId, binding);
803
+ }
804
+ /**
805
+ * Handle subagent.started event
806
+ */
807
+ handleSubagentStarted(taskId, event, binding) {
808
+ const { agentName, agentDisplayName, agentDescription, toolCallId } = event.data;
809
+ const subagentInfo = {
810
+ agentName,
811
+ agentDisplayName,
812
+ agentDescription,
813
+ toolCallId,
814
+ status: 'running',
815
+ startedAt: new Date().toISOString(),
816
+ };
817
+ binding.activeSubagents.set(toolCallId, subagentInfo);
818
+ taskManager.appendOutput(taskId, `[subagent] Started: ${agentDisplayName}`);
819
+ this.updateSessionMetrics(taskId, binding);
820
+ }
821
+ /**
822
+ * Handle subagent.completed event
823
+ */
824
+ handleSubagentCompleted(taskId, event, binding) {
825
+ const { agentName, toolCallId } = event.data;
826
+ const subagent = binding.activeSubagents.get(toolCallId);
827
+ if (subagent) {
828
+ subagent.status = 'completed';
829
+ subagent.endedAt = new Date().toISOString();
830
+ binding.completedSubagents.push(subagent);
831
+ binding.activeSubagents.delete(toolCallId);
832
+ }
833
+ taskManager.appendOutput(taskId, `[subagent] Completed: ${agentName}`);
834
+ this.updateSessionMetrics(taskId, binding);
835
+ }
836
+ /**
837
+ * Handle subagent.failed event
838
+ */
839
+ handleSubagentFailed(taskId, event, binding) {
840
+ const { agentName, toolCallId, error } = event.data;
841
+ const subagent = binding.activeSubagents.get(toolCallId);
842
+ if (subagent) {
843
+ subagent.status = 'failed';
844
+ subagent.error = error;
845
+ subagent.endedAt = new Date().toISOString();
846
+ binding.completedSubagents.push(subagent);
847
+ binding.activeSubagents.delete(toolCallId);
848
+ }
849
+ taskManager.appendOutput(taskId, `[subagent] Failed: ${agentName} - ${error}`);
850
+ this.updateSessionMetrics(taskId, binding);
851
+ }
852
+ /**
853
+ * Handle session.shutdown event - extract completion metrics
854
+ */
855
+ handleSessionShutdown(taskId, event, binding) {
856
+ // Flush any remaining buffered output before shutdown
857
+ if (binding.outputBuffer.length) {
858
+ taskManager.appendOutput(taskId, binding.outputBuffer.join(''));
859
+ binding.outputBuffer.length = 0;
860
+ }
861
+ if (binding.reasoningBuffer.length) {
862
+ taskManager.appendOutputFileOnly(taskId, `[reasoning] ${binding.reasoningBuffer.join('')}`);
863
+ binding.reasoningBuffer.length = 0;
864
+ }
865
+ // Session shutdown details → file only
866
+ taskManager.appendOutputFileOnly(taskId, `[session] Shutdown: ${event.data.shutdownType}`);
867
+ // Extract completion metrics from shutdown event
868
+ const completionMetrics = {
869
+ totalApiCalls: event.data.totalPremiumRequests || 0,
870
+ totalApiDurationMs: event.data.totalApiDurationMs || 0,
871
+ codeChanges: {
872
+ linesAdded: event.data.codeChanges?.linesAdded || 0,
873
+ linesRemoved: event.data.codeChanges?.linesRemoved || 0,
874
+ filesModified: event.data.codeChanges?.filesModified || [],
875
+ },
876
+ modelUsage: {},
877
+ sessionStartTime: binding.startTime.getTime(),
878
+ currentModel: event.data.modelMetrics ? Object.keys(event.data.modelMetrics)[0] : undefined,
879
+ };
880
+ // Process model metrics if available
881
+ if (event.data.modelMetrics) {
882
+ for (const [model, metricsRaw] of Object.entries(event.data.modelMetrics)) {
883
+ const metrics = metricsRaw;
884
+ completionMetrics.modelUsage[model] = {
885
+ requests: metrics.requests?.count || 0,
886
+ cost: metrics.requests?.cost || 0,
887
+ tokens: {
888
+ input: metrics.usage?.inputTokens || 0,
889
+ output: metrics.usage?.outputTokens || 0,
890
+ cacheRead: metrics.usage?.cacheReadTokens || 0,
891
+ cacheWrite: metrics.usage?.cacheWriteTokens || 0,
892
+ },
893
+ };
894
+ }
895
+ }
896
+ // Log completion metrics summary
897
+ if (completionMetrics.totalApiCalls > 0) {
898
+ taskManager.appendOutput(taskId, `[metrics] API calls: ${completionMetrics.totalApiCalls}, Duration: ${completionMetrics.totalApiDurationMs}ms`);
899
+ }
900
+ if (completionMetrics.codeChanges.linesAdded > 0 || completionMetrics.codeChanges.linesRemoved > 0) {
901
+ taskManager.appendOutput(taskId, `[metrics] Code: +${completionMetrics.codeChanges.linesAdded}/-${completionMetrics.codeChanges.linesRemoved} lines, ${completionMetrics.codeChanges.filesModified.length} files`);
902
+ }
903
+ // Update session metrics and completion metrics
904
+ this.updateSessionMetrics(taskId, binding);
905
+ taskManager.updateTask(taskId, { completionMetrics });
906
+ if (event.data.shutdownType === 'error' && !binding.isCompleted) {
907
+ binding.isCompleted = true;
908
+ taskManager.updateTask(taskId, {
909
+ status: TaskStatus.FAILED,
910
+ endTime: new Date().toISOString(),
911
+ error: event.data.errorReason || 'Session shutdown with error',
912
+ exitCode: 1,
913
+ session: undefined,
914
+ });
915
+ // Destroy session to release PTY FDs
916
+ this.unbind(taskId);
917
+ }
918
+ else if (binding.isCompleted) {
919
+ // Session shut down normally after completion — ensure cleanup
920
+ this.unbind(taskId);
921
+ }
922
+ }
923
+ /**
924
+ * Handle abort event
925
+ */
926
+ handleAbort(taskId, event, binding) {
927
+ // Flush any remaining buffered output before abort
928
+ if (binding.outputBuffer.length) {
929
+ taskManager.appendOutput(taskId, binding.outputBuffer.join(''));
930
+ binding.outputBuffer.length = 0;
931
+ }
932
+ if (binding.reasoningBuffer.length) {
933
+ taskManager.appendOutputFileOnly(taskId, `[reasoning] ${binding.reasoningBuffer.join('')}`);
934
+ binding.reasoningBuffer.length = 0;
935
+ }
936
+ taskManager.appendOutput(taskId, `[session] Aborted: ${event.data.reason}`);
937
+ if (!binding.isCompleted) {
938
+ binding.isCompleted = true;
939
+ this.updateSessionMetrics(taskId, binding);
940
+ taskManager.updateTask(taskId, {
941
+ status: TaskStatus.CANCELLED,
942
+ endTime: new Date().toISOString(),
943
+ session: undefined,
944
+ });
945
+ // Destroy session to release PTY FDs
946
+ this.unbind(taskId);
947
+ }
948
+ }
949
+ /**
950
+ * Update session metrics in task state
951
+ * Uses SDK's UsageMetricsTracker for token/request metrics, combined with our custom tracking
952
+ */
953
+ updateSessionMetrics(taskId, binding) {
954
+ const toolMetricsObj = {};
955
+ for (const [name, metrics] of binding.toolMetrics) {
956
+ toolMetricsObj[name] = metrics;
957
+ }
958
+ const quotasObj = {};
959
+ for (const [tier, quota] of binding.quotas) {
960
+ quotasObj[tier] = quota;
961
+ }
962
+ const sessionMetrics = {
963
+ quotas: quotasObj,
964
+ toolMetrics: toolMetricsObj,
965
+ activeSubagents: Array.from(binding.activeSubagents.values()),
966
+ completedSubagents: binding.completedSubagents,
967
+ turnCount: binding.turnCount,
968
+ totalTokens: {
969
+ input: binding.totalTokens.input,
970
+ output: binding.totalTokens.output,
971
+ },
972
+ };
973
+ taskManager.updateTask(taskId, { sessionMetrics });
974
+ }
975
+ /**
976
+ * Unbind a session from a task.
977
+ * Also destroys the session and removes it from the client manager's tracking
978
+ * to prevent PTY file descriptor leaks.
979
+ */
980
+ unbind(taskId) {
981
+ const binding = this.bindings.get(taskId);
982
+ if (binding && !binding.isUnbound) {
983
+ // Set flag first to prevent concurrent unbinds from double-destroying
984
+ binding.isUnbound = true;
985
+ binding.unsubscribe();
986
+ // Destroy the session to release PTY file descriptors (RC-1 fix)
987
+ const sessionId = binding.sessionId;
988
+ sdkClientManager.destroySession(sessionId).catch((err) => {
989
+ console.error(`[sdk-session-adapter] Failed to destroy session ${sessionId} during unbind:`, err);
990
+ });
991
+ this.bindings.delete(taskId);
992
+ console.error(`[sdk-session-adapter] Unbound and destroyed session ${sessionId} for task ${taskId}`);
993
+ }
994
+ }
995
+ /**
996
+ * Get the binding for a task.
997
+ */
998
+ getBinding(taskId) {
999
+ return this.bindings.get(taskId);
1000
+ }
1001
+ /**
1002
+ * Get the session for a task.
1003
+ */
1004
+ getSession(taskId) {
1005
+ return this.bindings.get(taskId)?.session;
1006
+ }
1007
+ /**
1008
+ * Check if a task has an active session.
1009
+ */
1010
+ hasSession(taskId) {
1011
+ return this.bindings.has(taskId);
1012
+ }
1013
+ /**
1014
+ * Get accumulated output for a task.
1015
+ */
1016
+ getOutput(taskId) {
1017
+ return this.bindings.get(taskId)?.outputBuffer || [];
1018
+ }
1019
+ /**
1020
+ * Mark a task as timed out.
1021
+ */
1022
+ markTimedOut(taskId, timeoutMs) {
1023
+ const binding = this.bindings.get(taskId);
1024
+ if (binding && !binding.isCompleted) {
1025
+ binding.isCompleted = true;
1026
+ this.updateSessionMetrics(taskId, binding);
1027
+ taskManager.updateTask(taskId, {
1028
+ status: TaskStatus.TIMED_OUT,
1029
+ endTime: new Date().toISOString(),
1030
+ error: `Task timed out after ${timeoutMs}ms`,
1031
+ timeoutReason: 'hard_timeout',
1032
+ timeoutContext: {
1033
+ timeoutMs,
1034
+ detectedBy: 'sdk_adapter',
1035
+ },
1036
+ session: undefined,
1037
+ });
1038
+ // Abort the session, then destroy to release PTY FDs
1039
+ // Safety timeout: if abort hangs for >10s, unbind anyway
1040
+ const abortTimeout = setTimeout(() => {
1041
+ console.error(`[sdk-session-adapter] Abort timed out for task ${taskId}, force unbinding`);
1042
+ this.unbind(taskId);
1043
+ }, 10_000);
1044
+ binding.session.abort().catch((err) => {
1045
+ console.error(`[sdk-session-adapter] Failed to abort timed-out session ${taskId}:`, err);
1046
+ }).finally(() => {
1047
+ clearTimeout(abortTimeout);
1048
+ this.unbind(taskId);
1049
+ });
1050
+ }
1051
+ }
1052
+ /**
1053
+ * Cleanup all bindings.
1054
+ * Destroys all sessions to release PTY file descriptors.
1055
+ */
1056
+ cleanup() {
1057
+ for (const [taskId, binding] of this.bindings) {
1058
+ binding.unsubscribe();
1059
+ // Destroy session to release PTY FDs (RC-6 fix)
1060
+ sdkClientManager.destroySession(binding.sessionId).catch((err) => {
1061
+ console.error(`[sdk-session-adapter] Failed to destroy session ${binding.sessionId} during cleanup:`, err);
1062
+ });
1063
+ }
1064
+ this.bindings.clear();
1065
+ }
1066
+ /**
1067
+ * Get statistics.
1068
+ */
1069
+ getStats() {
1070
+ let totalRotations = 0;
1071
+ let totalTurns = 0;
1072
+ const totalTokens = { input: 0, output: 0 };
1073
+ for (const binding of this.bindings.values()) {
1074
+ totalRotations += binding.rotationAttempts;
1075
+ totalTurns += binding.turnCount;
1076
+ totalTokens.input += binding.totalTokens.input;
1077
+ totalTokens.output += binding.totalTokens.output;
1078
+ }
1079
+ return {
1080
+ activeBindings: this.bindings.size,
1081
+ totalRotations,
1082
+ totalTurns,
1083
+ totalTokens,
1084
+ };
1085
+ }
1086
+ }
1087
+ export const sdkSessionAdapter = new SDKSessionAdapter();
1088
+ //# sourceMappingURL=sdk-session-adapter.js.map