@northflare/runner 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (154) hide show
  1. package/DEBUG_LOGGING.md +60 -0
  2. package/LICENSE +21 -0
  3. package/MIGRATION_PLAN.md +52 -0
  4. package/README.md +220 -0
  5. package/SDK_IMPLEMENTATION_GUIDE.md +1036 -0
  6. package/bin/northflare-runner +367 -0
  7. package/coverage/base.css +224 -0
  8. package/coverage/block-navigation.js +87 -0
  9. package/coverage/coverage-final.json +12 -0
  10. package/coverage/favicon.png +0 -0
  11. package/coverage/index.html +176 -0
  12. package/coverage/lib/index.html +116 -0
  13. package/coverage/lib/preload-script.js.html +964 -0
  14. package/coverage/prettify.css +1 -0
  15. package/coverage/prettify.js +2 -0
  16. package/coverage/sort-arrow-sprite.png +0 -0
  17. package/coverage/sorter.js +196 -0
  18. package/coverage/src/collections/index.html +116 -0
  19. package/coverage/src/collections/runner-messages.ts.html +312 -0
  20. package/coverage/src/components/claude-manager.ts.html +1290 -0
  21. package/coverage/src/components/index.html +146 -0
  22. package/coverage/src/components/message-handler.ts.html +730 -0
  23. package/coverage/src/components/repository-manager.ts.html +841 -0
  24. package/coverage/src/index.html +131 -0
  25. package/coverage/src/index.ts.html +448 -0
  26. package/coverage/src/runner.ts.html +1239 -0
  27. package/coverage/src/utils/config.ts.html +780 -0
  28. package/coverage/src/utils/console.ts.html +121 -0
  29. package/coverage/src/utils/index.html +161 -0
  30. package/coverage/src/utils/logger.ts.html +475 -0
  31. package/coverage/src/utils/status-line.ts.html +445 -0
  32. package/dist/collections/runner-messages.d.ts +52 -0
  33. package/dist/collections/runner-messages.d.ts.map +1 -0
  34. package/dist/collections/runner-messages.js +161 -0
  35. package/dist/collections/runner-messages.js.map +1 -0
  36. package/dist/components/claude-manager.d.ts +39 -0
  37. package/dist/components/claude-manager.d.ts.map +1 -0
  38. package/dist/components/claude-manager.js +783 -0
  39. package/dist/components/claude-manager.js.map +1 -0
  40. package/dist/components/claude-sdk-manager.d.ts +47 -0
  41. package/dist/components/claude-sdk-manager.d.ts.map +1 -0
  42. package/dist/components/claude-sdk-manager.js +1088 -0
  43. package/dist/components/claude-sdk-manager.js.map +1 -0
  44. package/dist/components/enhanced-repository-manager.d.ts +134 -0
  45. package/dist/components/enhanced-repository-manager.d.ts.map +1 -0
  46. package/dist/components/enhanced-repository-manager.js +602 -0
  47. package/dist/components/enhanced-repository-manager.js.map +1 -0
  48. package/dist/components/message-handler-sse.d.ts +46 -0
  49. package/dist/components/message-handler-sse.d.ts.map +1 -0
  50. package/dist/components/message-handler-sse.js +734 -0
  51. package/dist/components/message-handler-sse.js.map +1 -0
  52. package/dist/components/message-handler.d.ts +35 -0
  53. package/dist/components/message-handler.d.ts.map +1 -0
  54. package/dist/components/message-handler.js +689 -0
  55. package/dist/components/message-handler.js.map +1 -0
  56. package/dist/components/repository-manager.d.ts +51 -0
  57. package/dist/components/repository-manager.d.ts.map +1 -0
  58. package/dist/components/repository-manager.js +295 -0
  59. package/dist/components/repository-manager.js.map +1 -0
  60. package/dist/index.d.ts +9 -0
  61. package/dist/index.d.ts.map +1 -0
  62. package/dist/index.js +166 -0
  63. package/dist/index.js.map +1 -0
  64. package/dist/runner-sse.d.ts +57 -0
  65. package/dist/runner-sse.d.ts.map +1 -0
  66. package/dist/runner-sse.js +698 -0
  67. package/dist/runner-sse.js.map +1 -0
  68. package/dist/runner.d.ts +51 -0
  69. package/dist/runner.d.ts.map +1 -0
  70. package/dist/runner.js +530 -0
  71. package/dist/runner.js.map +1 -0
  72. package/dist/services/RunnerAPIClient.d.ts +30 -0
  73. package/dist/services/RunnerAPIClient.d.ts.map +1 -0
  74. package/dist/services/RunnerAPIClient.js +112 -0
  75. package/dist/services/RunnerAPIClient.js.map +1 -0
  76. package/dist/services/SSEClient.d.ts +60 -0
  77. package/dist/services/SSEClient.d.ts.map +1 -0
  78. package/dist/services/SSEClient.js +204 -0
  79. package/dist/services/SSEClient.js.map +1 -0
  80. package/dist/types/claude.d.ts +45 -0
  81. package/dist/types/claude.d.ts.map +1 -0
  82. package/dist/types/claude.js +6 -0
  83. package/dist/types/claude.js.map +1 -0
  84. package/dist/types/index.d.ts +47 -0
  85. package/dist/types/index.d.ts.map +1 -0
  86. package/dist/types/index.js +23 -0
  87. package/dist/types/index.js.map +1 -0
  88. package/dist/types/messages.d.ts +31 -0
  89. package/dist/types/messages.d.ts.map +1 -0
  90. package/dist/types/messages.js +6 -0
  91. package/dist/types/messages.js.map +1 -0
  92. package/dist/types/runner-interface.d.ts +24 -0
  93. package/dist/types/runner-interface.d.ts.map +1 -0
  94. package/dist/types/runner-interface.js +6 -0
  95. package/dist/types/runner-interface.js.map +1 -0
  96. package/dist/utils/StateManager.d.ts +52 -0
  97. package/dist/utils/StateManager.d.ts.map +1 -0
  98. package/dist/utils/StateManager.js +162 -0
  99. package/dist/utils/StateManager.js.map +1 -0
  100. package/dist/utils/config.d.ts +41 -0
  101. package/dist/utils/config.d.ts.map +1 -0
  102. package/dist/utils/config.js +250 -0
  103. package/dist/utils/config.js.map +1 -0
  104. package/dist/utils/console.d.ts +11 -0
  105. package/dist/utils/console.d.ts.map +1 -0
  106. package/dist/utils/console.js +15 -0
  107. package/dist/utils/console.js.map +1 -0
  108. package/dist/utils/expand-env.d.ts +2 -0
  109. package/dist/utils/expand-env.d.ts.map +1 -0
  110. package/dist/utils/expand-env.js +20 -0
  111. package/dist/utils/expand-env.js.map +1 -0
  112. package/dist/utils/logger.d.ts +9 -0
  113. package/dist/utils/logger.d.ts.map +1 -0
  114. package/dist/utils/logger.js +108 -0
  115. package/dist/utils/logger.js.map +1 -0
  116. package/dist/utils/status-line.d.ts +37 -0
  117. package/dist/utils/status-line.d.ts.map +1 -0
  118. package/dist/utils/status-line.js +113 -0
  119. package/dist/utils/status-line.js.map +1 -0
  120. package/docs/claude-manager.md +91 -0
  121. package/exceptions.log +22 -0
  122. package/lib/preload-script.js +293 -0
  123. package/package.json +55 -0
  124. package/rejections.log +63 -0
  125. package/runner.log +488 -0
  126. package/src/components/claude-sdk-manager.ts +1354 -0
  127. package/src/components/enhanced-repository-manager.ts +823 -0
  128. package/src/components/message-handler-sse.ts +1011 -0
  129. package/src/components/repository-manager.ts +337 -0
  130. package/src/index.ts +166 -0
  131. package/src/runner-sse.ts +847 -0
  132. package/src/services/RunnerAPIClient.ts +135 -0
  133. package/src/services/SSEClient.ts +258 -0
  134. package/src/types/claude.ts +55 -0
  135. package/src/types/computer-name.d.ts +4 -0
  136. package/src/types/index.ts +63 -0
  137. package/src/types/messages.ts +39 -0
  138. package/src/types/runner-interface.ts +34 -0
  139. package/src/utils/StateManager.ts +187 -0
  140. package/src/utils/codex-sdk.js +448 -0
  141. package/src/utils/config.ts +315 -0
  142. package/src/utils/console.ts +13 -0
  143. package/src/utils/expand-env.ts +22 -0
  144. package/src/utils/logger.ts +131 -0
  145. package/src/utils/sdk-demo.js +34 -0
  146. package/src/utils/status-line.ts +121 -0
  147. package/test-debug.sh +26 -0
  148. package/tests/retry-strategies.test.ts +410 -0
  149. package/tests/sdk-integration.test.ts +329 -0
  150. package/tests/sdk-streaming.test.ts +1180 -0
  151. package/tests/setup.ts +5 -0
  152. package/tests/test-claude-manager.ts +120 -0
  153. package/tsconfig.json +36 -0
  154. package/vitest.config.ts +27 -0
@@ -0,0 +1,1354 @@
1
+ /**
2
+ * ClaudeManager - Manages Claude conversations using SDK-native patterns
3
+ *
4
+ * This component handles stateful conversation lifecycle management, maintaining
5
+ * persistent conversation instances indexed by taskId. Uses the SDK's native
6
+ * capabilities including the builder pattern, native session management, and
7
+ * built-in streaming instead of custom wrappers.
8
+ *
9
+ * Key improvements:
10
+ * - Uses claude() builder pattern for simplified configuration
11
+ * - Native session management with withSessionId()
12
+ * - Direct AsyncGenerator streaming without custom wrappers
13
+ * - SDK's onProcessComplete() for proper cleanup
14
+ * - Simplified error handling while maintaining compatibility
15
+ */
16
+
17
+ import { query as sdkQuery } from "@anthropic-ai/claude-agent-sdk";
18
+ // Keep SDKMessage loosely typed to avoid tight coupling to SDK typings
19
+ type SDKMessage = any;
20
+ import { IRunnerApp } from "../types/runner-interface";
21
+ import { EnhancedRepositoryManager } from "./enhanced-repository-manager";
22
+ import { ConversationContext, ConversationConfig, Message } from "../types";
23
+ import { statusLineManager } from "../utils/status-line";
24
+ import { console } from "../utils/console";
25
+ import { expandEnv } from "../utils/expand-env";
26
+ import * as jwt from "jsonwebtoken";
27
+
28
+ export class ClaudeManager {
29
+ private runner: IRunnerApp;
30
+ private repositoryManager: EnhancedRepositoryManager;
31
+
32
+ constructor(
33
+ runner: IRunnerApp,
34
+ repositoryManager: EnhancedRepositoryManager
35
+ ) {
36
+ this.runner = runner;
37
+ this.repositoryManager = repositoryManager;
38
+
39
+ // Log debug mode status
40
+ if (process.env["DEBUG"] === "true") {
41
+ console.log(
42
+ "[ClaudeManager] DEBUG MODE ENABLED - Claude SDK will log verbose output"
43
+ );
44
+ }
45
+
46
+ // Note: MCP host configuration is passed from orchestrator
47
+ // Runner does not define its own MCP tools
48
+ this.setupInternalMcpServer();
49
+ }
50
+
51
+ private setupInternalMcpServer(): void {
52
+ // Runner does not define its own MCP tools
53
+ // All MCP tool configuration is passed from orchestrator in conversation config
54
+ }
55
+
56
+ async startConversation(
57
+ conversationObjectType: "Task" | "TaskPlan",
58
+ conversationObjectId: string,
59
+ config: ConversationConfig,
60
+ initialMessages: Message[],
61
+ conversationData?: {
62
+ id: string;
63
+ objectType: string;
64
+ objectId: string;
65
+ model: string;
66
+ globalInstructions: string;
67
+ workspaceInstructions: string;
68
+ permissionsMode: string;
69
+ agentSessionId: string;
70
+ }
71
+ ): Promise<ConversationContext> {
72
+ // Returns conversation context
73
+ // Greenfield: conversationData.id is required as the authoritative DB conversation ID
74
+ if (!conversationData?.id) {
75
+ throw new Error(
76
+ "startConversation requires conversationData with a valid conversation.id"
77
+ );
78
+ }
79
+ // Use sessionId from config if resuming, or agentSessionId from conversationData if available
80
+ const agentSessionId =
81
+ config.sessionId || conversationData.agentSessionId || "";
82
+ const conversationId = conversationData.id;
83
+
84
+ const context: ConversationContext = {
85
+ conversationId,
86
+ agentSessionId, // Will be updated by onSessionId callback for new conversations
87
+ conversationObjectType,
88
+ conversationObjectId,
89
+ taskId:
90
+ conversationObjectType === "Task" ? conversationObjectId : undefined,
91
+ workspaceId: config.workspaceId,
92
+ status: "starting",
93
+ config,
94
+ startedAt: new Date(),
95
+ lastActivityAt: new Date(),
96
+ // Add conversation details if provided
97
+ model: conversationData?.model || "sonnet",
98
+ globalInstructions: conversationData?.globalInstructions || "",
99
+ workspaceInstructions: conversationData?.workspaceInstructions || "",
100
+ permissionsMode: conversationData?.permissionsMode || "all",
101
+ };
102
+
103
+ // Store with conversation.id as the key
104
+ this.runner.activeConversations_.set(conversationId, context);
105
+ console.log(`[ClaudeManager] Stored conversation context:`, {
106
+ conversationId,
107
+ agentSessionId: context.agentSessionId,
108
+ conversationObjectType: context.conversationObjectType,
109
+ conversationObjectId: context.conversationObjectId,
110
+ mapSize: this.runner.activeConversations_.size,
111
+ allKeys: Array.from(this.runner.activeConversations_.keys()),
112
+ });
113
+
114
+ const workspaceId = config.workspaceId;
115
+
116
+ // Checkout repository if specified
117
+ let workspacePath: string;
118
+
119
+ // Check if this is a local workspace by looking for runnerRepoPath in config
120
+ if (config.runnerRepoPath) {
121
+ // Local workspace - use the provided path directly
122
+ workspacePath = config.runnerRepoPath;
123
+ console.log(`Using local workspace path: ${workspacePath}`);
124
+
125
+ // For task conversations in local workspaces, create a local task handle
126
+ if (conversationObjectType === "Task") {
127
+ const taskHandle = await this.repositoryManager.createLocalTaskHandle(
128
+ conversationObjectId,
129
+ workspacePath
130
+ );
131
+ // Store task handle information in context for later use
132
+ (context as any).taskHandle = taskHandle;
133
+ }
134
+ } else if (
135
+ conversationObjectType === "Task" &&
136
+ config.repository &&
137
+ workspaceId
138
+ ) {
139
+ // Use task-specific worktree for task conversations
140
+ console.log(
141
+ `[ClaudeManager] Creating task worktree with repository config:`,
142
+ {
143
+ conversationObjectId,
144
+ workspaceId,
145
+ repository: config.repository,
146
+ hasUrl: !!config.repository.url,
147
+ url: config.repository.url,
148
+ branch: config.repository.branch,
149
+ type: config.repository.type,
150
+ }
151
+ );
152
+
153
+ // Check if repository.url is missing
154
+ if (!config.repository.url) {
155
+ throw new Error(
156
+ `Repository URL is missing in config for task ${conversationObjectId}. Repository config: ${JSON.stringify(
157
+ config.repository
158
+ )}`
159
+ );
160
+ }
161
+
162
+ const taskHandle = await this.repositoryManager.createTaskWorktree(
163
+ conversationObjectId,
164
+ workspaceId,
165
+ config.repository.url,
166
+ config.repository.branch,
167
+ config.githubToken
168
+ );
169
+ workspacePath = taskHandle.worktreePath;
170
+
171
+ // Store task handle information in context for later use
172
+ (context as any).taskHandle = taskHandle;
173
+ } else if (config.repository && workspaceId) {
174
+ // Check if it's a local repository
175
+ if (config.repository.type === "local" && config.repository.localPath) {
176
+ // Use the local path directly
177
+ workspacePath = config.repository.localPath;
178
+ console.log(`Using local repository path: ${workspacePath}`);
179
+ } else {
180
+ // Fall back to workspace-based checkout for non-task conversations
181
+ workspacePath = await this.repositoryManager.checkoutRepository(
182
+ workspaceId,
183
+ config.repository.url,
184
+ config.repository.branch,
185
+ config.githubToken
186
+ );
187
+ }
188
+ } else if (workspaceId) {
189
+ workspacePath = await this.repositoryManager.getWorkspacePath(
190
+ workspaceId
191
+ );
192
+ } else {
193
+ // Default workspace path when no workspaceId is provided
194
+ workspacePath = process.cwd();
195
+ }
196
+
197
+ // Fetch GitHub tokens from orchestrator API if we have a workspaceId
198
+ const githubToken = workspaceId
199
+ ? await this.fetchGithubTokens(workspaceId)
200
+ : undefined;
201
+
202
+ // Generate TOOL_TOKEN for MCP tools authentication
203
+ let toolToken: string | undefined;
204
+ if (config.mcpServers && Object.keys(config.mcpServers).length > 0) {
205
+ const runnerToken = process.env["NORTHFLARE_RUNNER_TOKEN"];
206
+ const runnerUid = this.runner.getRunnerUid();
207
+
208
+ if (runnerToken && runnerUid && context.conversationId) {
209
+ // Sign JWT with runner's token
210
+ toolToken = jwt.sign(
211
+ {
212
+ conversationId: context.conversationId,
213
+ runnerUid: runnerUid,
214
+ },
215
+ runnerToken,
216
+ {
217
+ expiresIn: "60m", // 60 minutes expiry
218
+ }
219
+ );
220
+ console.log(
221
+ "[ClaudeManager] Generated TOOL_TOKEN for MCP authentication"
222
+ );
223
+ } else {
224
+ console.warn(
225
+ "[ClaudeManager] Unable to generate TOOL_TOKEN - missing required data"
226
+ );
227
+ }
228
+ }
229
+
230
+ // Simplified SDK configuration - using native query API with streamlined options
231
+ // const cliPath = require.resolve("@anthropic-ai/claude-code/cli.js");
232
+
233
+ // Debug logging for executable paths
234
+ //console.log("[ClaudeManager] SDK executable paths:", {
235
+ // cliPath,
236
+ // cwd: workspacePath,
237
+ //});
238
+
239
+ // Simplified environment configuration
240
+ const envVars: Record<string, string> = {
241
+ ...(Object.fromEntries(
242
+ Object.entries(process.env).filter(([_, value]) => value !== undefined)
243
+ ) as Record<string, string>), // Preserve parent environment, filter out undefined values
244
+ };
245
+
246
+ if (config.anthropicApiKey) {
247
+ envVars["ANTHROPIC_API_KEY"] = config.anthropicApiKey;
248
+ }
249
+
250
+ if (config.accessToken) {
251
+ envVars["CLAUDE_CODE_OAUTH_TOKEN"] = config.accessToken;
252
+ }
253
+
254
+ if (githubToken) {
255
+ envVars["GITHUB_TOKEN"] = githubToken;
256
+ }
257
+
258
+ if (process.env["DEBUG"] === "true") {
259
+ envVars["DEBUG"] = "1";
260
+ }
261
+
262
+ // Simplified system prompt handling
263
+ const appendSystemPrompt = [
264
+ context.globalInstructions,
265
+ context.workspaceInstructions,
266
+ ]
267
+ .filter(Boolean)
268
+ .join("\n\n");
269
+
270
+ // Simplified tool restrictions for read-only mode
271
+ const disallowedTools: string[] =
272
+ context.permissionsMode === "read"
273
+ ? [
274
+ "Write",
275
+ "Edit",
276
+ "MultiEdit",
277
+ "Bash",
278
+ "KillBash",
279
+ "NotebookEdit",
280
+ "ExitPlanMode",
281
+ ]
282
+ : [];
283
+
284
+ if (disallowedTools.length) {
285
+ console.log("[ClaudeManager] Applied read-only mode tool restrictions");
286
+ }
287
+
288
+ // Simplified MCP server configuration
289
+ let mcpServers: Record<string, any> | undefined;
290
+ if (config.mcpServers) {
291
+ console.log(
292
+ "[ClaudeManager] MCP servers configuration:",
293
+ JSON.stringify(config.mcpServers, null, 2)
294
+ );
295
+ mcpServers = expandEnv(config.mcpServers, { TOOL_TOKEN: toolToken });
296
+ }
297
+
298
+ // Create input stream for user messages (simplified message queueing)
299
+ const input = createUserMessageStream();
300
+
301
+ // Launch SDK with simplified configuration
302
+ const sdk = sdkQuery({
303
+ prompt: input.iterable,
304
+ options: {
305
+ //pathToClaudeCodeExecutable: cliPath,
306
+ cwd: workspacePath,
307
+ env: envVars,
308
+ model: context.model,
309
+ permissionMode: "bypassPermissions", // Runner handles permissions
310
+ resume:
311
+ config.sessionId || conversationData?.agentSessionId || undefined,
312
+ ...(appendSystemPrompt ? { appendSystemPrompt } : {}),
313
+ ...(disallowedTools.length ? { disallowedTools } : {}),
314
+ ...(mcpServers ? { mcpServers } : {}),
315
+ ...(process.env["DEBUG"] === "true"
316
+ ? {
317
+ stderr: (data: string) => {
318
+ try {
319
+ console.log(`[Claude SDK] ${data}`);
320
+ } catch {}
321
+ },
322
+ }
323
+ : {}),
324
+ },
325
+ });
326
+
327
+ // Simplified conversation wrapper - reduced complexity while maintaining interface
328
+ const conversation = createConversationWrapper(
329
+ sdk,
330
+ input,
331
+ async (hadError: boolean, error?: any) => {
332
+ await this._finalizeConversation(context, hadError, error);
333
+ }
334
+ );
335
+
336
+ // Store conversation instance in context for reuse
337
+ context.conversation = conversation as any;
338
+
339
+ // Observe session id from first messages that include it
340
+ conversation.onSessionId(async (agentSessionId: string) => {
341
+ if (!agentSessionId) return;
342
+ const oldSessionId = context.agentSessionId;
343
+ if (oldSessionId !== agentSessionId) {
344
+ context.agentSessionId = agentSessionId;
345
+ context.status = "active";
346
+ await this.runner.notify("agentSessionId.changed", {
347
+ conversationId: context.conversationId,
348
+ conversationObjectType,
349
+ conversationObjectId,
350
+ oldAgentSessionId: oldSessionId,
351
+ newAgentSessionId: agentSessionId,
352
+ });
353
+ }
354
+ });
355
+
356
+ // Set up streaming message handler
357
+ const messageHandler = async (
358
+ message: SDKMessage,
359
+ sessionId: string | null
360
+ ) => {
361
+ await this.handleStreamedMessage(context, message, sessionId);
362
+
363
+ // Heuristic: detect terminal system events and finalize
364
+ try {
365
+ if (message?.type === "system") {
366
+ const m: any = message;
367
+ const subtype = m.subtype || m?.message?.subtype || m?.event;
368
+ const exitSignals = [
369
+ "exit",
370
+ "exiting",
371
+ "session_end",
372
+ "conversation_end",
373
+ "process_exit",
374
+ "done",
375
+ "completed",
376
+ ];
377
+ if (subtype && exitSignals.includes(String(subtype))) {
378
+ await this._finalizeConversation(context, false);
379
+ }
380
+ } else if (message?.type === "result") {
381
+ // Treat 'result' as an end-of-conversation signal for the SDK
382
+ try {
383
+ await this._finalizeConversation(context, false);
384
+ console.log(
385
+ "[ClaudeManager] Finalized conversation due to SDK 'result' message",
386
+ {
387
+ conversationId: context.conversationId,
388
+ agentSessionId: context.agentSessionId,
389
+ }
390
+ );
391
+ } catch (e) {
392
+ console.warn(
393
+ "[ClaudeManager] Error finalizing on 'result' message:",
394
+ e
395
+ );
396
+ }
397
+ }
398
+ } catch (e) {
399
+ console.warn("[ClaudeManager] finalize-on-system heuristic error:", e);
400
+ }
401
+ };
402
+
403
+ conversation.stream(messageHandler);
404
+
405
+ // Note: Error handling is done via process completion handler
406
+ // The Claude SDK doesn't have an onError method on conversations
407
+
408
+ // Send initial messages
409
+ try {
410
+ for (const message of initialMessages) {
411
+ const initialText = this.normalizeToText(message.content);
412
+ conversation.send({
413
+ type: "text",
414
+ text: initialText,
415
+ });
416
+ }
417
+
418
+ console.log(
419
+ `Started conversation for ${conversationObjectType} ${conversationObjectId} in workspace ${workspacePath}`
420
+ );
421
+
422
+ // Return the conversation context directly
423
+ return context;
424
+ } catch (error) {
425
+ // Handle startup errors
426
+ await this._handleConversationError(context, error as Error);
427
+ throw error;
428
+ }
429
+ }
430
+
431
+ async stopConversation(
432
+ agentSessionId: string,
433
+ context: ConversationContext,
434
+ isRunnerShutdown: boolean = false,
435
+ reason?: string
436
+ ): Promise<void> {
437
+ if (context && context.conversation) {
438
+ context.status = "stopping";
439
+
440
+ try {
441
+ // Proactively send end notification to avoid missing it on abnormal exits
442
+ // Use provided reason, or fall back to 'runner_shutdown' if isRunnerShutdown is true
443
+ const finalReason =
444
+ reason || (isRunnerShutdown ? "runner_shutdown" : undefined);
445
+ await this._finalizeConversation(
446
+ context,
447
+ false,
448
+ undefined,
449
+ finalReason
450
+ ).catch(() => {});
451
+ // Mark conversation as stopped BEFORE ending to prevent race conditions
452
+ context.status = "stopped";
453
+
454
+ // Properly end the conversation using the SDK
455
+ await context.conversation.end();
456
+ } catch (error) {
457
+ console.error(`Error ending conversation ${agentSessionId}:`, error);
458
+ }
459
+
460
+ // Clean up conversation reference
461
+ delete context.conversation;
462
+ }
463
+
464
+ console.log(
465
+ `Stopped conversation ${agentSessionId} for ${context.conversationObjectType} ${context.conversationObjectId}`
466
+ );
467
+ }
468
+
469
+ async resumeConversation(
470
+ conversationObjectType: "Task" | "TaskPlan",
471
+ conversationObjectId: string,
472
+ agentSessionId: string,
473
+ config: ConversationConfig,
474
+ conversationData?: any,
475
+ resumeMessage?: string
476
+ ): Promise<string> {
477
+ console.log(`[ClaudeManager] Resuming conversation ${agentSessionId}`);
478
+
479
+ // Resume is handled by starting a new conversation with the existing session ID
480
+ const context = await this.startConversation(
481
+ conversationObjectType,
482
+ conversationObjectId,
483
+ { ...config, sessionId: agentSessionId },
484
+ [], // Don't send initial messages
485
+ conversationData
486
+ );
487
+
488
+ // After starting the conversation with the sessionId, we need to send a message
489
+ // to actually trigger the Claude process to continue
490
+ if (context.conversation) {
491
+ try {
492
+ // Use the provided resume message or default to system instruction
493
+ const messageToSend =
494
+ resumeMessage ||
495
+ "<system-instructions>Please continue</system-instructions>";
496
+ console.log(
497
+ `[ClaudeManager] Sending resume message to conversation ${agentSessionId}`
498
+ );
499
+ context.conversation.send({
500
+ type: "text",
501
+ text: messageToSend,
502
+ });
503
+ } catch (error) {
504
+ console.error(`[ClaudeManager] Error sending resume message:`, error);
505
+ }
506
+ }
507
+
508
+ return context.agentSessionId;
509
+ }
510
+
511
+ private async _finalizeConversation(
512
+ context: ConversationContext,
513
+ hadError: boolean,
514
+ error?: any,
515
+ reason?: string
516
+ ): Promise<void> {
517
+ // Ensure idempotency
518
+ if ((context as any)._finalized) return;
519
+ (context as any)._finalized = true;
520
+
521
+ try {
522
+ await this.runner.notify("conversation.end", {
523
+ conversationId: context.conversationId,
524
+ conversationObjectType: context.conversationObjectType,
525
+ conversationObjectId: context.conversationObjectId,
526
+ agentSessionId: context.agentSessionId,
527
+ isError: hadError,
528
+ errorMessage: error?.message,
529
+ reason: reason,
530
+ });
531
+ } catch (e) {
532
+ console.error("[ClaudeManager] Failed to notify conversation.end:", e);
533
+ }
534
+
535
+ // Clean up conversation from active conversations
536
+ try {
537
+ console.log(`[ClaudeManager] Removing conversation from active map:`, {
538
+ conversationId: context.conversationId,
539
+ agentSessionId: context.agentSessionId,
540
+ mapSizeBefore: this.runner.activeConversations_.size,
541
+ });
542
+ this.runner.activeConversations_.delete(context.conversationId);
543
+ statusLineManager.updateActiveCount(
544
+ this.runner.activeConversations_.size
545
+ );
546
+ } catch {}
547
+ }
548
+
549
+ async sendUserMessage(
550
+ conversationId: string,
551
+ content: any,
552
+ config?: ConversationConfig,
553
+ conversationObjectType?: "Task" | "TaskPlan",
554
+ conversationObjectId?: string,
555
+ conversation?: any
556
+ ): Promise<void> {
557
+ console.log(`[ClaudeManager] sendUserMessage called with:`, {
558
+ conversationId,
559
+ conversationObjectType,
560
+ conversationObjectId,
561
+ hasConfig: !!config,
562
+ hasConversation: !!conversation,
563
+ activeConversations: this.runner.activeConversations_.size,
564
+ });
565
+
566
+ // Find by conversationId only
567
+ let context = this.runner.getConversationContext(conversationId);
568
+ console.log(`[ClaudeManager] Lookup by conversationId result:`, {
569
+ found: !!context,
570
+ conversationId: context?.conversationId,
571
+ agentSessionId: context?.agentSessionId,
572
+ });
573
+
574
+ if (!context && conversation) {
575
+ // Use provided conversation details
576
+ try {
577
+ const conversationDetails = conversation;
578
+ console.log(
579
+ `[ClaudeManager] Using provided config from RunnerMessage:`,
580
+ {
581
+ hasConfig: !!config,
582
+ hasRepository: !!config?.repository,
583
+ repositoryType: config?.repository?.type,
584
+ repositoryPath: config?.repository?.localPath,
585
+ hasTokens: !!(config?.accessToken || config?.githubToken),
586
+ hasMcpServers: !!config?.mcpServers,
587
+ }
588
+ );
589
+
590
+ // Use the config that was already prepared by TaskOrchestrator and sent in the RunnerMessage
591
+ const startConfig: ConversationConfig = {
592
+ anthropicApiKey:
593
+ config?.anthropicApiKey || process.env["ANTHROPIC_API_KEY"] || "",
594
+ systemPrompt: config?.systemPrompt,
595
+ workspaceId: conversationDetails.workspaceId,
596
+ ...config, // Use the full config provided in the RunnerMessage
597
+ ...(conversationDetails.agentSessionId
598
+ ? { sessionId: conversationDetails.agentSessionId }
599
+ : {}),
600
+ };
601
+
602
+ // Start the SDK conversation (no initial messages); this attaches to existing session when provided
603
+ await this.startConversation(
604
+ conversationDetails.objectType,
605
+ conversationDetails.objectId,
606
+ startConfig,
607
+ [],
608
+ conversationDetails
609
+ );
610
+
611
+ // Refresh context after start/resume
612
+ context = this.runner.getConversationContext(conversationId);
613
+ } catch (error) {
614
+ console.error(`Failed to fetch conversation ${conversationId}:`, error);
615
+ }
616
+ }
617
+
618
+ if (!context) {
619
+ throw new Error(
620
+ `No active or fetchable conversation found for ${conversationId}`
621
+ );
622
+ }
623
+
624
+ try {
625
+ // Send immediately when a conversation instance exists; no need to wait for "active"
626
+ if (!context.conversation) {
627
+ throw new Error(
628
+ `No conversation instance found for conversation ${context.conversationId}`
629
+ );
630
+ }
631
+
632
+ // Guard: Don't send messages if conversation is stopped or stopping
633
+ const conversationStatus = context.status as string;
634
+ if (
635
+ conversationStatus === "stopped" ||
636
+ conversationStatus === "stopping"
637
+ ) {
638
+ console.warn(
639
+ `Attempted to send message to stopped/stopping conversation ${context.conversationId}`,
640
+ {
641
+ status: context.status,
642
+ conversationObjectType: context.conversationObjectType,
643
+ conversationObjectId: context.conversationObjectId,
644
+ }
645
+ );
646
+ return;
647
+ }
648
+
649
+ // Native message injection - SDK handles queueing and delivery
650
+ const normalizedText = this.normalizeToText(content);
651
+
652
+ if (process.env["DEBUG"] === "true") {
653
+ console.log("[ClaudeManager] Normalized follow-up content", {
654
+ originalType: typeof content,
655
+ isArray: Array.isArray(content) || undefined,
656
+ normalizedPreview:
657
+ typeof normalizedText === "string"
658
+ ? normalizedText.slice(0, 160)
659
+ : String(normalizedText).slice(0, 160),
660
+ });
661
+ }
662
+
663
+ context.conversation.send({
664
+ type: "text",
665
+ text: normalizedText,
666
+ });
667
+
668
+ // Update last activity timestamp
669
+ context.lastActivityAt = new Date();
670
+
671
+ console.log(
672
+ `Sent user message to conversation ${context.conversationId} (agentSessionId: ${context.agentSessionId}, status: ${context.status})`
673
+ );
674
+ } catch (error) {
675
+ // Handle errors properly
676
+ await this._handleConversationError(context, error as Error);
677
+ throw error; // Re-throw to maintain the same behavior
678
+ }
679
+ }
680
+
681
+ private async fetchGithubTokens(
682
+ workspaceId: string
683
+ ): Promise<string | undefined> {
684
+ try {
685
+ const response = await fetch(
686
+ `${this.runner.config_.orchestratorUrl}/api/runner/tokens?workspaceId=${workspaceId}`,
687
+ {
688
+ method: "GET",
689
+ headers: {
690
+ Authorization: `Bearer ${process.env["NORTHFLARE_RUNNER_TOKEN"]}`,
691
+ },
692
+ }
693
+ );
694
+
695
+ if (!response.ok) {
696
+ console.error(`Failed to fetch GitHub tokens: ${response.status}`);
697
+ return undefined;
698
+ }
699
+
700
+ const data = (await response.json()) as { githubToken?: string };
701
+ return data.githubToken;
702
+ } catch (error) {
703
+ console.error("Error fetching GitHub tokens:", error);
704
+ return undefined;
705
+ }
706
+ }
707
+
708
+ private async _handleConversationError(
709
+ context: ConversationContext,
710
+ error: Error
711
+ ): Promise<void> {
712
+ const errorType = this.classifyError(error);
713
+
714
+ // Notify orchestrator
715
+ await this.runner.notify("error.report", {
716
+ conversationId: context.conversationId,
717
+ conversationObjectType: context.conversationObjectType,
718
+ conversationObjectId: context.conversationObjectId,
719
+ agentSessionId: context.agentSessionId,
720
+ errorType,
721
+ message: error.message,
722
+ details: {
723
+ stack: error.stack,
724
+ timestamp: new Date(),
725
+ },
726
+ });
727
+
728
+ // Conversation continues on error - no automatic cleanup
729
+ console.error(
730
+ `Conversation error for ${context.conversationObjectType} ${context.conversationObjectId}:`,
731
+ error
732
+ );
733
+ }
734
+
735
+ private classifyError(error: Error): string {
736
+ if (error.message.includes("process exited")) {
737
+ return "process_exit";
738
+ } else if (error.message.includes("model")) {
739
+ return "model_error";
740
+ } else if (error.message.includes("tool")) {
741
+ return "tool_error";
742
+ } else if (error.message.includes("permission")) {
743
+ return "permission_error";
744
+ } else if (error.message.includes("timeout")) {
745
+ return "timeout_error";
746
+ }
747
+ return "unknown_error";
748
+ }
749
+
750
+ /**
751
+ * Normalize arbitrary content shapes into a plain string for the CLI
752
+ */
753
+ private normalizeToText(value: any): string {
754
+ if (typeof value === "string") return value;
755
+ if (value == null) return "";
756
+
757
+ if (typeof value === "object") {
758
+ // Common simple shapes
759
+ if (typeof (value as any).text === "string") return (value as any).text;
760
+ if (
761
+ typeof (value as any).text === "object" &&
762
+ (value as any).text &&
763
+ typeof (value as any).text.text === "string"
764
+ )
765
+ return (value as any).text.text;
766
+ if (typeof (value as any).content === "string")
767
+ return (value as any).content;
768
+
769
+ // Array of blocks: [{ type: 'text', text: '...' }, ...]
770
+ if (Array.isArray(value)) {
771
+ const texts = value
772
+ .map((b) =>
773
+ b && b.type === "text" && typeof b.text === "string" ? b.text : null
774
+ )
775
+ .filter((t): t is string => !!t);
776
+ if (texts.length) return texts.join(" ");
777
+ }
778
+
779
+ // Nested message shapes
780
+ if (
781
+ typeof (value as any).message === "object" &&
782
+ (value as any).message &&
783
+ typeof (value as any).message.text === "string"
784
+ ) {
785
+ return (value as any).message.text;
786
+ }
787
+ }
788
+
789
+ try {
790
+ return JSON.stringify(value);
791
+ } catch {
792
+ return String(value);
793
+ }
794
+ }
795
+
796
+ private async handleStreamedMessage(
797
+ context: ConversationContext,
798
+ message: SDKMessage,
799
+ sessionId: string | null
800
+ ): Promise<void> {
801
+ /*
802
+ * SDK tool call payload reference (observed shapes)
803
+ *
804
+ * 1) Assistant tool call (tool_use)
805
+ * {
806
+ * "type": "assistant",
807
+ * "message": {
808
+ * "role": "assistant",
809
+ * "content": [
810
+ * { "type": "text", "text": "…optional text…" },
811
+ * { "type": "tool_use", "id": "toolu_01Nbv…", "name": "TodoWrite", "input": { ... } }
812
+ * ]
813
+ * },
814
+ * "session_id": "…"
815
+ * }
816
+ *
817
+ * 2) Tool result (often emitted as type 'user')
818
+ * {
819
+ * "type": "user",
820
+ * "message": {
821
+ * "role": "user",
822
+ * "content": [
823
+ * {
824
+ * "tool_use_id": "toolu_01E9R475…",
825
+ * "type": "tool_result",
826
+ * "content": "Todos have been modified successfully. …"
827
+ * }
828
+ * ]
829
+ * },
830
+ * "parent_tool_use_id": null,
831
+ * "session_id": "…",
832
+ * "uuid": "…"
833
+ * }
834
+ *
835
+ * Normalization (runner → server message.agent):
836
+ * - assistant tool_use → type: 'assistant', content: [{ toolCalls: [{ id, name, arguments }] }, …]
837
+ * - tool_result (any shape) → type: 'tool_result', subtype: 'tool_result', content:
838
+ * [ { type: 'tool_result', subtype: 'tool_result', tool_use_id, content } ]
839
+ */
840
+ // Guard: Don't process messages if conversation is stopped or stopping
841
+ const status = context.status as string;
842
+ if (status === "stopped" || status === "stopping") {
843
+ console.log(
844
+ `Ignoring message for stopped/stopping conversation ${context.conversationId}`,
845
+ {
846
+ status: context.status,
847
+ messageType: message.type,
848
+ }
849
+ );
850
+ return;
851
+ }
852
+
853
+ try {
854
+ // High-level receipt log
855
+ console.log(
856
+ `Received streamed message for ${context.conversationObjectType} ${context.conversationObjectId}`,
857
+ {
858
+ type: message?.type,
859
+ }
860
+ );
861
+
862
+ // Raw SDK message diagnostics
863
+ try {
864
+ // Log the full raw SDK message safely (handles circular refs)
865
+ const safeStringify = (obj: any) => {
866
+ const seen = new WeakSet();
867
+ return JSON.stringify(
868
+ obj,
869
+ (key, value) => {
870
+ if (typeof value === "function") return undefined;
871
+ if (typeof value === "bigint") return String(value);
872
+ if (typeof value === "object" && value !== null) {
873
+ if (seen.has(value)) return "[Circular]";
874
+ seen.add(value);
875
+ }
876
+ return value;
877
+ },
878
+ 2
879
+ );
880
+ };
881
+
882
+ console.log(
883
+ "[ClaudeManager] RAW SDK message FULL:",
884
+ safeStringify(message)
885
+ );
886
+
887
+ const summary = {
888
+ keys: Object.keys(message || {}),
889
+ hasMessage: !!(message as any)?.message,
890
+ contentType: typeof (message as any)?.content,
891
+ messageContentType: typeof (message as any)?.message?.content,
892
+ sessionId:
893
+ (message as any)?.session_id || (message as any)?.sessionId || null,
894
+ };
895
+ console.log("[ClaudeManager] RAW SDK message summary:", summary);
896
+ if ((message as any)?.content !== undefined) {
897
+ console.log(
898
+ "[ClaudeManager] RAW SDK content:",
899
+ (message as any).content
900
+ );
901
+ }
902
+ if ((message as any)?.message?.content !== undefined) {
903
+ console.log(
904
+ "[ClaudeManager] RAW SDK nested content:",
905
+ (message as any).message.content
906
+ );
907
+ }
908
+ } catch (e) {
909
+ console.warn("[ClaudeManager] Failed to log raw SDK message:", e);
910
+ }
911
+
912
+ // Build structured content based on message type
913
+ let messageType: string = message.type;
914
+ let subtype: string | undefined;
915
+ let structuredContent: any = {};
916
+ let isError = false;
917
+ let skipSend = false;
918
+
919
+ // Extract content based on message type
920
+ switch (message.type) {
921
+ case "assistant": {
922
+ const assistantMsg = message as any;
923
+ const blocks =
924
+ assistantMsg?.message?.content || assistantMsg?.content || [];
925
+ const textContent = Array.isArray(blocks)
926
+ ? blocks
927
+ .filter(
928
+ (b: any) =>
929
+ b && b.type === "text" && typeof b.text === "string"
930
+ )
931
+ .map((b: any) => b.text)
932
+ .join("")
933
+ : "";
934
+ const toolCalls = Array.isArray(blocks)
935
+ ? blocks
936
+ .filter((b: any) => b && b.type === "tool_use")
937
+ .map((b: any) => ({
938
+ id: b.id,
939
+ name: b.name,
940
+ arguments: b.input,
941
+ }))
942
+ : undefined;
943
+
944
+ structuredContent = {
945
+ ...(textContent ? { text: textContent } : {}),
946
+ ...(toolCalls && toolCalls.length ? { toolCalls } : {}),
947
+ timestamp: new Date().toISOString(),
948
+ };
949
+ break;
950
+ }
951
+
952
+ case "thinking" as any: {
953
+ // Map thinking to assistant with subtype
954
+ messageType = "assistant";
955
+ subtype = "thinking";
956
+ const thinkingMsg = message as any;
957
+ structuredContent = {
958
+ text: thinkingMsg.content || "",
959
+ timestamp: new Date().toISOString(),
960
+ };
961
+ break;
962
+ }
963
+
964
+ case "tool_use" as any: {
965
+ // Tool call request - map to assistant
966
+ messageType = "assistant";
967
+ subtype = "tool_use";
968
+ const toolUseMsg = message as any;
969
+ structuredContent = {
970
+ toolCalls: [
971
+ {
972
+ id: toolUseMsg.id,
973
+ name: toolUseMsg.name,
974
+ arguments: toolUseMsg.input,
975
+ },
976
+ ],
977
+ timestamp: new Date().toISOString(),
978
+ };
979
+ break;
980
+ }
981
+
982
+ case "tool_result" as any: {
983
+ // Tool execution result - normalize to v1-style tool_result blocks
984
+ // so the server persists as messageType: tool_call_result with correct content
985
+ messageType = "tool_result";
986
+ subtype = "tool_result";
987
+ const toolResultMsg = message as any;
988
+ structuredContent = [
989
+ {
990
+ type: "tool_result",
991
+ subtype: "tool_result",
992
+ tool_use_id: toolResultMsg.tool_use_id,
993
+ content: toolResultMsg.content, // Keep content as native (array or string)
994
+ timestamp: new Date().toISOString(),
995
+ },
996
+ ];
997
+ break;
998
+ }
999
+
1000
+ case "result": {
1001
+ const resultMsg = message as any;
1002
+ structuredContent = {
1003
+ text: resultMsg.content || resultMsg.result || "",
1004
+ timestamp: new Date().toISOString(),
1005
+ };
1006
+ break;
1007
+ }
1008
+
1009
+ case "user": {
1010
+ const userMsg = message as any;
1011
+ // Prefer nested message.content if present (SDK shape), fallback to top-level content
1012
+ const blocks =
1013
+ (userMsg && userMsg.message && userMsg.message.content) ||
1014
+ userMsg?.content ||
1015
+ [];
1016
+
1017
+ if (Array.isArray(blocks)) {
1018
+ const hasToolResult = blocks.some(
1019
+ (b: any) => b && typeof b === "object" && b.type === "tool_result"
1020
+ );
1021
+
1022
+ if (hasToolResult) {
1023
+ // Normalize tool_result blocks to v1-style
1024
+ messageType = "tool_result";
1025
+ subtype = "tool_result";
1026
+ structuredContent = blocks
1027
+ .filter((b: any) => b && b.type === "tool_result")
1028
+ .map((b: any) => ({
1029
+ type: "tool_result",
1030
+ subtype: "tool_result",
1031
+ tool_use_id: b.tool_use_id || b.toolUseId || b.id,
1032
+ content: b.content, // Keep content as native (array or string)
1033
+ }));
1034
+ } else {
1035
+ // Treat as plain text content by joining text blocks
1036
+ const textContent = blocks
1037
+ .filter(
1038
+ (b: any) =>
1039
+ b && b.type === "text" && typeof b.text === "string"
1040
+ )
1041
+ .map((b: any) => b.text)
1042
+ .join("");
1043
+ structuredContent = {
1044
+ text: textContent,
1045
+ timestamp: new Date().toISOString(),
1046
+ };
1047
+ }
1048
+
1049
+ // If content array only contains empty objects, skip sending
1050
+ if (
1051
+ Array.isArray(structuredContent) &&
1052
+ structuredContent.length > 0 &&
1053
+ structuredContent.every(
1054
+ (it: any) =>
1055
+ !it || typeof it !== "object" || Object.keys(it).length === 0
1056
+ )
1057
+ ) {
1058
+ console.log(
1059
+ "[ClaudeManager] Skipping empty 'user' message with only empty objects from SDK"
1060
+ );
1061
+ skipSend = true;
1062
+ }
1063
+ } else if (typeof userMsg?.content === "string") {
1064
+ // Attempt to parse JSON arrays (common for tool_result payloads)
1065
+ const text = userMsg.content;
1066
+ try {
1067
+ const parsed = JSON.parse(text);
1068
+ if (Array.isArray(parsed)) {
1069
+ const hasToolResult = parsed.some(
1070
+ (item: any) =>
1071
+ item &&
1072
+ typeof item === "object" &&
1073
+ item.type === "tool_result"
1074
+ );
1075
+ if (hasToolResult) {
1076
+ messageType = "tool_result";
1077
+ subtype = "tool_result";
1078
+ structuredContent = parsed;
1079
+ } else {
1080
+ structuredContent = {
1081
+ text,
1082
+ timestamp: new Date().toISOString(),
1083
+ };
1084
+ }
1085
+ } else {
1086
+ structuredContent = {
1087
+ text,
1088
+ timestamp: new Date().toISOString(),
1089
+ };
1090
+ }
1091
+ } catch {
1092
+ // Not JSON - treat as plain text
1093
+ structuredContent = { text, timestamp: new Date().toISOString() };
1094
+ }
1095
+ } else {
1096
+ // Other object content - preserve as is
1097
+ structuredContent = userMsg?.content || {};
1098
+ }
1099
+ break;
1100
+ }
1101
+
1102
+ case "system": {
1103
+ const systemMsg = message as any;
1104
+ const subtype = systemMsg.subtype || "system";
1105
+ const model = systemMsg.model || systemMsg?.message?.model;
1106
+ const permissionMode =
1107
+ systemMsg.permissionMode || systemMsg?.message?.permissionMode;
1108
+ const summary = [
1109
+ subtype && `[${subtype}]`,
1110
+ model && `model=${model}`,
1111
+ permissionMode && `perm=${permissionMode}`,
1112
+ ]
1113
+ .filter(Boolean)
1114
+ .join(" ");
1115
+ structuredContent = {
1116
+ text: summary || "",
1117
+ timestamp: new Date().toISOString(),
1118
+ };
1119
+ break;
1120
+ }
1121
+
1122
+ case "error" as any: {
1123
+ const errorMsg = message as any;
1124
+ messageType = "system";
1125
+ subtype = "error";
1126
+ isError = true;
1127
+ structuredContent = {
1128
+ text: errorMsg.message || errorMsg.error || "Unknown error",
1129
+ errorType: errorMsg.error_type || "unknown",
1130
+ errorDetails: {
1131
+ stack: errorMsg.stack,
1132
+ code: errorMsg.code,
1133
+ context: errorMsg,
1134
+ },
1135
+ timestamp: new Date().toISOString(),
1136
+ };
1137
+ break;
1138
+ }
1139
+
1140
+ default: {
1141
+ // Unknown message type - log and send as assistant
1142
+ const unknownMsg = message as any;
1143
+ console.warn(`Unknown message type: ${unknownMsg.type}`, message);
1144
+ messageType = "assistant";
1145
+ structuredContent = {
1146
+ text: JSON.stringify(message),
1147
+ timestamp: new Date().toISOString(),
1148
+ };
1149
+ }
1150
+ }
1151
+
1152
+ // Generate a unique message ID
1153
+ const messageId = `${context.agentSessionId}-${Date.now()}-${Math.random()
1154
+ .toString(36)
1155
+ .substr(2, 9)}`;
1156
+
1157
+ // Send agent message to orchestrator with structured content
1158
+ // Skip if conversation is stopping/stopped to avoid race conditions
1159
+ const currentStatus = context.status as string;
1160
+ if (currentStatus !== "stopped" && currentStatus !== "stopping") {
1161
+ if (skipSend) {
1162
+ console.log(
1163
+ "[ClaudeManager] Not sending message.agent due to skipSend=true"
1164
+ );
1165
+ return;
1166
+ }
1167
+
1168
+ const payload = {
1169
+ conversationId: context.conversationId,
1170
+ conversationObjectType: context.conversationObjectType,
1171
+ conversationObjectId: context.conversationObjectId,
1172
+ agentSessionId: context.agentSessionId,
1173
+ type: messageType,
1174
+ subtype,
1175
+ content: Array.isArray(structuredContent)
1176
+ ? structuredContent
1177
+ : [structuredContent],
1178
+ messageId,
1179
+ isError,
1180
+ };
1181
+
1182
+ try {
1183
+ console.log("[ClaudeManager] Sending message.agent payload:", {
1184
+ type: payload.type,
1185
+ subtype: payload.subtype,
1186
+ contentPreview: Array.isArray(payload.content)
1187
+ ? payload.content.slice(0, 1)
1188
+ : payload.content,
1189
+ });
1190
+ } catch {}
1191
+
1192
+ await this.runner.notify("message.agent", payload as any);
1193
+ }
1194
+
1195
+ // Tool calls are now handled directly by Claude through the MCP server
1196
+ // We just log that we saw them but don't intercept or process them
1197
+ if (
1198
+ structuredContent.toolCalls &&
1199
+ structuredContent.toolCalls.length > 0
1200
+ ) {
1201
+ console.log(
1202
+ `Claude is making ${structuredContent.toolCalls.length} tool call(s) via MCP`,
1203
+ {
1204
+ conversationObjectId: context.conversationObjectId,
1205
+ toolNames: structuredContent.toolCalls.map((tc: any) => tc.name),
1206
+ }
1207
+ );
1208
+ }
1209
+ } catch (error) {
1210
+ // Check if this is a transport error due to stopped conversation
1211
+ const errorMessage =
1212
+ error instanceof Error ? error.message : String(error);
1213
+ const isTransportError =
1214
+ errorMessage.includes("Cannot read properties of undefined") ||
1215
+ errorMessage.includes("stdout") ||
1216
+ errorMessage.includes("transport");
1217
+
1218
+ const statusCheck = context.status as string;
1219
+ if (
1220
+ isTransportError &&
1221
+ (statusCheck === "stopped" || statusCheck === "stopping")
1222
+ ) {
1223
+ // This is expected when conversation is stopped - just log it
1224
+ console.log(
1225
+ `Transport error for stopped/stopping conversation ${context.conversationId} (expected):`,
1226
+ errorMessage
1227
+ );
1228
+ return;
1229
+ }
1230
+
1231
+ console.error(
1232
+ `Error handling streamed message for ${context.conversationObjectType} ${context.conversationObjectId}:`,
1233
+ error
1234
+ );
1235
+ await this._handleConversationError(context, error as Error);
1236
+ }
1237
+ }
1238
+ }
1239
+
1240
+ // Simplified helper functions for SDK integration
1241
+
1242
+ type UserMessageStream = {
1243
+ iterable: AsyncIterable<any>;
1244
+ enqueue: (msg: any) => void;
1245
+ close: () => void;
1246
+ };
1247
+
1248
+ function createUserMessageStream(): UserMessageStream {
1249
+ const queue: any[] = [];
1250
+ let resolver: (() => void) | null = null;
1251
+ let done = false;
1252
+
1253
+ async function* iterator() {
1254
+ while (true) {
1255
+ if (queue.length > 0) {
1256
+ const value = queue.shift();
1257
+ yield value;
1258
+ continue;
1259
+ }
1260
+ if (done) return;
1261
+ await new Promise<void>((resolve) => (resolver = resolve));
1262
+ resolver = null;
1263
+ }
1264
+ }
1265
+
1266
+ return {
1267
+ iterable: iterator(),
1268
+ enqueue: (msg: any) => {
1269
+ if (done) return;
1270
+ queue.push(msg);
1271
+ if (resolver) {
1272
+ const r = resolver;
1273
+ resolver = null;
1274
+ r();
1275
+ }
1276
+ },
1277
+ close: () => {
1278
+ done = true;
1279
+ if (resolver) {
1280
+ const r = resolver;
1281
+ resolver = null;
1282
+ r();
1283
+ }
1284
+ },
1285
+ };
1286
+ }
1287
+
1288
+ function createConversationWrapper(
1289
+ sdk: any,
1290
+ input: UserMessageStream,
1291
+ onComplete?: (hadError: boolean, error?: any) => Promise<void> | void
1292
+ ) {
1293
+ let onSessionIdCb: ((id: string) => void) | null = null;
1294
+ let observedSessionId: string | null = null;
1295
+ let startedReader = false;
1296
+
1297
+ function toSdkUserMessage(text: string) {
1298
+ return {
1299
+ type: "user",
1300
+ session_id: observedSessionId || "",
1301
+ parent_tool_use_id: null,
1302
+ message: {
1303
+ role: "user",
1304
+ content: text,
1305
+ },
1306
+ };
1307
+ }
1308
+
1309
+ return {
1310
+ send(payload: { type: string; text: string }) {
1311
+ const text = payload?.text ?? "";
1312
+ input.enqueue(toSdkUserMessage(text));
1313
+ },
1314
+ async end() {
1315
+ try {
1316
+ input.close();
1317
+ } finally {
1318
+ // Simplified process cleanup
1319
+ try {
1320
+ if (sdk?.abortController) {
1321
+ sdk.abortController.abort();
1322
+ }
1323
+ } catch {}
1324
+ }
1325
+ },
1326
+ onSessionId(cb: (id: string) => void) {
1327
+ onSessionIdCb = cb;
1328
+ },
1329
+ stream(
1330
+ handler: (message: SDKMessage, sessionId: string | null) => Promise<void>
1331
+ ) {
1332
+ if (startedReader) return;
1333
+ startedReader = true;
1334
+ (async () => {
1335
+ try {
1336
+ for await (const msg of sdk) {
1337
+ // Simplified session ID extraction
1338
+ const sid = (msg && (msg.session_id || msg.sessionId)) || null;
1339
+ if (sid && sid !== observedSessionId) {
1340
+ observedSessionId = sid;
1341
+ if (onSessionIdCb) onSessionIdCb(sid);
1342
+ }
1343
+ await handler(msg, sid);
1344
+ }
1345
+ // Normal completion
1346
+ if (onComplete) await onComplete(false);
1347
+ } catch (e) {
1348
+ // Error completion
1349
+ if (onComplete) await onComplete(true, e);
1350
+ }
1351
+ })();
1352
+ },
1353
+ };
1354
+ }