@northflare/runner 0.0.8 → 0.0.9

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 (68) hide show
  1. package/dist/components/claude-sdk-manager.d.ts +1 -1
  2. package/dist/components/claude-sdk-manager.d.ts.map +1 -1
  3. package/dist/components/claude-sdk-manager.js +25 -12
  4. package/dist/components/claude-sdk-manager.js.map +1 -1
  5. package/dist/components/codex-sdk-manager.d.ts +58 -0
  6. package/dist/components/codex-sdk-manager.d.ts.map +1 -0
  7. package/dist/components/codex-sdk-manager.js +907 -0
  8. package/dist/components/codex-sdk-manager.js.map +1 -0
  9. package/dist/components/message-handler-sse.d.ts +3 -0
  10. package/dist/components/message-handler-sse.d.ts.map +1 -1
  11. package/dist/components/message-handler-sse.js +51 -7
  12. package/dist/components/message-handler-sse.js.map +1 -1
  13. package/dist/runner-sse.d.ts +4 -0
  14. package/dist/runner-sse.d.ts.map +1 -1
  15. package/dist/runner-sse.js +145 -15
  16. package/dist/runner-sse.js.map +1 -1
  17. package/dist/types/claude.d.ts +11 -1
  18. package/dist/types/claude.d.ts.map +1 -1
  19. package/dist/types/runner-interface.d.ts +2 -0
  20. package/dist/types/runner-interface.d.ts.map +1 -1
  21. package/dist/utils/model.d.ts +6 -0
  22. package/dist/utils/model.d.ts.map +1 -0
  23. package/dist/utils/model.js +23 -0
  24. package/dist/utils/model.js.map +1 -0
  25. package/dist/utils/status-line.d.ts +2 -2
  26. package/dist/utils/status-line.js +5 -5
  27. package/dist/utils/status-line.js.map +1 -1
  28. package/lib/codex-sdk/.prettierignore +3 -0
  29. package/lib/codex-sdk/.prettierrc +5 -0
  30. package/lib/codex-sdk/README.md +133 -0
  31. package/lib/codex-sdk/dist/index.d.ts +260 -0
  32. package/lib/codex-sdk/dist/index.js +426 -0
  33. package/lib/codex-sdk/eslint.config.js +21 -0
  34. package/lib/codex-sdk/jest.config.cjs +31 -0
  35. package/lib/codex-sdk/package.json +65 -0
  36. package/lib/codex-sdk/samples/basic_streaming.ts +90 -0
  37. package/lib/codex-sdk/samples/helpers.ts +8 -0
  38. package/lib/codex-sdk/samples/structured_output.ts +22 -0
  39. package/lib/codex-sdk/samples/structured_output_zod.ts +19 -0
  40. package/lib/codex-sdk/src/codex.ts +38 -0
  41. package/lib/codex-sdk/src/codexOptions.ts +10 -0
  42. package/lib/codex-sdk/src/events.ts +80 -0
  43. package/lib/codex-sdk/src/exec.ts +336 -0
  44. package/lib/codex-sdk/src/index.ts +39 -0
  45. package/lib/codex-sdk/src/items.ts +127 -0
  46. package/lib/codex-sdk/src/outputSchemaFile.ts +40 -0
  47. package/lib/codex-sdk/src/thread.ts +155 -0
  48. package/lib/codex-sdk/src/threadOptions.ts +18 -0
  49. package/lib/codex-sdk/src/turnOptions.ts +6 -0
  50. package/lib/codex-sdk/tests/abort.test.ts +165 -0
  51. package/lib/codex-sdk/tests/codexExecSpy.ts +37 -0
  52. package/lib/codex-sdk/tests/responsesProxy.ts +225 -0
  53. package/lib/codex-sdk/tests/run.test.ts +687 -0
  54. package/lib/codex-sdk/tests/runStreamed.test.ts +211 -0
  55. package/lib/codex-sdk/tsconfig.json +24 -0
  56. package/lib/codex-sdk/tsup.config.ts +12 -0
  57. package/package.json +3 -1
  58. package/rejections.log +2 -0
  59. package/src/components/claude-sdk-manager.ts +33 -13
  60. package/src/components/codex-sdk-manager.ts +1248 -0
  61. package/src/components/message-handler-sse.ts +79 -8
  62. package/src/runner-sse.ts +174 -15
  63. package/src/types/claude.ts +12 -1
  64. package/src/types/runner-interface.ts +3 -1
  65. package/src/utils/model.ts +29 -0
  66. package/src/utils/status-line.ts +6 -6
  67. package/src/utils/codex-sdk.js +0 -448
  68. package/src/utils/sdk-demo.js +0 -34
@@ -0,0 +1,1248 @@
1
+ import type { Thread, ThreadEvent, ThreadItem } from "@northflare/codex-sdk";
2
+ import { IRunnerApp } from "../types/runner-interface";
3
+ import { EnhancedRepositoryManager } from "./enhanced-repository-manager";
4
+ import { ConversationContext, ConversationConfig, Message } from "../types";
5
+ import { statusLineManager } from "../utils/status-line";
6
+ import { console } from "../utils/console";
7
+ import { expandEnv } from "../utils/expand-env";
8
+ import { parseModelValue } from "../utils/model";
9
+ import * as jwt from "jsonwebtoken";
10
+ import fs from "fs/promises";
11
+ import path from "path";
12
+
13
+ let codexSdkPromise: Promise<typeof import("@northflare/codex-sdk")> | null =
14
+ null;
15
+ // Use runtime dynamic import so the CommonJS build can load the ESM-only SDK.
16
+ const dynamicImport = new Function(
17
+ "specifier",
18
+ "return import(specifier);"
19
+ ) as (specifier: string) => Promise<unknown>;
20
+
21
+ async function loadCodexSdk() {
22
+ if (!codexSdkPromise) {
23
+ codexSdkPromise = dynamicImport("@northflare/codex-sdk") as Promise<
24
+ typeof import("@northflare/codex-sdk")
25
+ >;
26
+ }
27
+ return codexSdkPromise;
28
+ }
29
+
30
+ type CodexThreadState = {
31
+ thread: Thread;
32
+ abortController: AbortController | null;
33
+ runPromise: Promise<void> | null;
34
+ };
35
+
36
+ type ConversationDetails = {
37
+ id: string;
38
+ objectType: string;
39
+ objectId: string;
40
+ model: string;
41
+ globalInstructions: string;
42
+ workspaceInstructions: string;
43
+ permissionsMode: string;
44
+ agentSessionId?: string;
45
+ workspaceId?: string;
46
+ threadId?: string;
47
+ };
48
+
49
+ type NormalizedItemEvent = {
50
+ type: string;
51
+ content: any;
52
+ subtype?: string;
53
+ toolCalls?: any;
54
+ isError?: boolean;
55
+ };
56
+
57
+ export class CodexManager {
58
+ private runner: IRunnerApp;
59
+ private repositoryManager: EnhancedRepositoryManager;
60
+ private threadStates: Map<string, CodexThreadState> = new Map();
61
+
62
+ constructor(
63
+ runner: IRunnerApp,
64
+ repositoryManager: EnhancedRepositoryManager
65
+ ) {
66
+ this.runner = runner;
67
+ this.repositoryManager = repositoryManager;
68
+ }
69
+
70
+ async startConversation(
71
+ conversationObjectType: "Task" | "TaskPlan",
72
+ conversationObjectId: string,
73
+ config: ConversationConfig,
74
+ initialMessages: Message[],
75
+ conversationData?: ConversationDetails
76
+ ): Promise<ConversationContext> {
77
+ if (!conversationData?.id) {
78
+ throw new Error(
79
+ "startConversation requires conversationData with a valid conversation.id"
80
+ );
81
+ }
82
+
83
+ const conversationId = conversationData.id;
84
+ const agentSessionId = this.resolveSessionId(config, conversationData);
85
+
86
+ const rawModel =
87
+ conversationData?.model ||
88
+ (config as any)?.model ||
89
+ (config as any)?.defaultModel ||
90
+ "openai";
91
+ const { baseModel, reasoningEffort } = parseModelValue(rawModel);
92
+ const normalizedModel = baseModel || rawModel;
93
+
94
+ const metadata: Record<string, any> = {
95
+ instructionsInjected: false,
96
+ originalModelValue: rawModel,
97
+ };
98
+
99
+ if (reasoningEffort) {
100
+ metadata["modelReasoningEffort"] = reasoningEffort;
101
+ }
102
+
103
+ const context: ConversationContext = {
104
+ conversationId,
105
+ agentSessionId,
106
+ conversationObjectType,
107
+ conversationObjectId,
108
+ taskId:
109
+ conversationObjectType === "Task" ? conversationObjectId : undefined,
110
+ workspaceId: config.workspaceId,
111
+ status: "starting",
112
+ config,
113
+ startedAt: new Date(),
114
+ lastActivityAt: new Date(),
115
+ model: normalizedModel,
116
+ globalInstructions: conversationData.globalInstructions || "",
117
+ workspaceInstructions: conversationData.workspaceInstructions || "",
118
+ permissionsMode:
119
+ conversationData.permissionsMode ||
120
+ (config as any)?.permissionsMode ||
121
+ "all",
122
+ provider: "openai",
123
+ metadata,
124
+ };
125
+
126
+ this.runner.activeConversations_.set(conversationId, context);
127
+
128
+ console.log(`[CodexManager] Stored conversation context`, {
129
+ conversationId,
130
+ agentSessionId,
131
+ model: context.model,
132
+ permissionsMode: context.permissionsMode,
133
+ });
134
+
135
+ const workspacePath = await this.resolveWorkspacePath(context, config);
136
+
137
+ (context.metadata as Record<string, any>)["workspacePath"] = workspacePath;
138
+
139
+ const githubToken = context.workspaceId
140
+ ? await this.fetchGithubTokens(context.workspaceId)
141
+ : undefined;
142
+
143
+ const toolToken = this.generateToolToken(context);
144
+
145
+ let mcpServers: Record<string, any> | undefined;
146
+ if (config.mcpServers && Object.keys(config.mcpServers).length > 0) {
147
+ const expandedServers = expandEnv(config.mcpServers, {
148
+ TOOL_TOKEN: toolToken,
149
+ });
150
+ mcpServers = this.normalizeMcpServersForCodex(expandedServers);
151
+ console.log(
152
+ "[CodexManager] MCP servers configuration:",
153
+ JSON.stringify(mcpServers, null, 2)
154
+ );
155
+ (context.metadata as Record<string, any>)["mcpServers"] = mcpServers;
156
+ }
157
+
158
+ const configOverrides = this.buildConfigOverridesFromMcp(mcpServers);
159
+
160
+ const envVars = await this.buildEnvVars(config, githubToken, toolToken);
161
+
162
+ const { Codex } = await loadCodexSdk();
163
+ const codex = new Codex({
164
+ baseUrl:
165
+ (config as any)?.openaiBaseUrl ||
166
+ (config as any)?.codexBaseUrl ||
167
+ process.env["OPENAI_BASE_URL"],
168
+ apiKey:
169
+ (config as any)?.openaiApiKey ||
170
+ (config as any)?.codexApiKey ||
171
+ process.env["CODEX_API_KEY"] ||
172
+ process.env["OPENAI_API_KEY"],
173
+ env: envVars,
174
+ });
175
+
176
+ const threadOptions = {
177
+ model: context.model,
178
+ workingDirectory: workspacePath,
179
+ sandboxMode: this.mapSandboxMode(context.permissionsMode),
180
+ networkAccessEnabled: this.shouldEnableNetwork(context.permissionsMode),
181
+ webSearchEnabled: true,
182
+ // additionalDirectories: this.getAdditionalDirectories(config),
183
+ configOverrides,
184
+ skipGitRepoCheck: true,
185
+ modelReasoningEffort: reasoningEffort,
186
+ } as const;
187
+
188
+ console.log(
189
+ "[CodexManager] Thread options:",
190
+ JSON.stringify(threadOptions, null, 2)
191
+ );
192
+
193
+ const thread = agentSessionId
194
+ ? codex.resumeThread(agentSessionId, threadOptions)
195
+ : codex.startThread(threadOptions);
196
+
197
+ this.threadStates.set(conversationId, {
198
+ thread,
199
+ abortController: null,
200
+ runPromise: null,
201
+ });
202
+
203
+ context.conversation = thread;
204
+
205
+ // Send initial messages sequentially without waiting for completion
206
+ if (initialMessages?.length) {
207
+ for (const message of initialMessages) {
208
+ const text = this.normalizeToText(message.content);
209
+ const prompt = this.buildPromptForMessage(context, text, true);
210
+ this.launchTurn(context, prompt);
211
+ }
212
+ }
213
+
214
+ return context;
215
+ }
216
+
217
+ async stopConversation(
218
+ _agentSessionId: string,
219
+ context: ConversationContext,
220
+ isRunnerShutdown: boolean = false,
221
+ reason?: string
222
+ ): Promise<void> {
223
+ context.status = "stopping";
224
+
225
+ await this.abortActiveRun(context.conversationId);
226
+
227
+ try {
228
+ await this._finalizeConversation(
229
+ context,
230
+ false,
231
+ undefined,
232
+ reason || (isRunnerShutdown ? "runner_shutdown" : undefined)
233
+ );
234
+ } catch (error) {
235
+ console.error(
236
+ `[CodexManager] Error finalizing conversation ${context.conversationId}:`,
237
+ error
238
+ );
239
+ } finally {
240
+ this.threadStates.delete(context.conversationId);
241
+ }
242
+
243
+ context.status = "stopped";
244
+ }
245
+
246
+ async resumeConversation(
247
+ conversationObjectType: "Task" | "TaskPlan",
248
+ conversationObjectId: string,
249
+ agentSessionId: string,
250
+ config: ConversationConfig,
251
+ conversationData?: ConversationDetails,
252
+ resumeMessage?: string
253
+ ): Promise<string> {
254
+ console.log(`[CodexManager] Resuming conversation ${agentSessionId}`);
255
+
256
+ const context = await this.startConversation(
257
+ conversationObjectType,
258
+ conversationObjectId,
259
+ { ...config, sessionId: agentSessionId },
260
+ [],
261
+ conversationData
262
+ );
263
+
264
+ if (resumeMessage) {
265
+ const prompt = this.buildPromptForMessage(context, resumeMessage, false);
266
+ this.launchTurn(context, prompt);
267
+ }
268
+
269
+ return context.agentSessionId;
270
+ }
271
+
272
+ async sendUserMessage(
273
+ conversationId: string,
274
+ content: any,
275
+ config?: ConversationConfig,
276
+ conversationObjectType?: "Task" | "TaskPlan",
277
+ conversationObjectId?: string,
278
+ conversation?: any,
279
+ agentSessionIdOverride?: string
280
+ ): Promise<void> {
281
+ console.log(`[CodexManager] sendUserMessage called`, {
282
+ conversationId,
283
+ hasConfig: !!config,
284
+ });
285
+
286
+ let context = this.runner.getConversationContext(conversationId);
287
+
288
+ if (!context && conversation) {
289
+ const conversationDetails = conversation as ConversationDetails;
290
+ const resumeSessionId = this.resolveSessionId(
291
+ config,
292
+ conversationDetails,
293
+ agentSessionIdOverride
294
+ );
295
+ const startConfig: ConversationConfig = {
296
+ ...(config || {}),
297
+ workspaceId:
298
+ conversationDetails.workspaceId || config?.workspaceId || undefined,
299
+ ...(resumeSessionId ? { sessionId: resumeSessionId } : {}),
300
+ };
301
+
302
+ context = await this.startConversation(
303
+ (conversationObjectType as "Task" | "TaskPlan") ||
304
+ (conversationDetails.objectType as "Task" | "TaskPlan"),
305
+ conversationObjectId || conversationDetails.objectId,
306
+ startConfig,
307
+ [],
308
+ conversationDetails
309
+ );
310
+ }
311
+
312
+ if (!context) {
313
+ throw new Error(
314
+ `No active or fetchable conversation found for ${conversationId}`
315
+ );
316
+ }
317
+
318
+ await this.abortActiveRun(conversationId);
319
+
320
+ const messageText = this.normalizeToText(content);
321
+ const prompt = this.buildPromptForMessage(context, messageText, false);
322
+ this.launchTurn(context, prompt);
323
+
324
+ context.lastActivityAt = new Date();
325
+ }
326
+
327
+ private async buildEnvVars(
328
+ config: ConversationConfig,
329
+ githubToken?: string,
330
+ toolToken?: string
331
+ ): Promise<Record<string, string>> {
332
+ const envVars: Record<string, string> = {
333
+ ...(Object.fromEntries(
334
+ Object.entries(process.env).filter(([, value]) => value !== undefined)
335
+ ) as Record<string, string>),
336
+ };
337
+
338
+ if (config.codexAuth?.accessToken) {
339
+ envVars["OPENAI_ACCESS_TOKEN"] = config.codexAuth.accessToken;
340
+ } else if (config.accessToken) {
341
+ envVars["OPENAI_ACCESS_TOKEN"] = config.accessToken;
342
+ }
343
+
344
+ if (githubToken) {
345
+ envVars["GITHUB_TOKEN"] = githubToken;
346
+ }
347
+
348
+ if (toolToken) {
349
+ envVars["TOOL_TOKEN"] = toolToken;
350
+ }
351
+
352
+ if (process.env["DEBUG"] === "true") {
353
+ envVars["DEBUG"] = "1";
354
+ }
355
+
356
+ const codexHome = await this.ensureCodexAuthHome(config);
357
+ if (codexHome) {
358
+ envVars["CODEX_HOME"] = codexHome;
359
+ }
360
+
361
+ return envVars;
362
+ }
363
+
364
+ private async ensureCodexAuthHome(
365
+ config: ConversationConfig
366
+ ): Promise<string | null> {
367
+ if (!config.codexAuth) {
368
+ return null;
369
+ }
370
+
371
+ const runnerId = this.runner.getRunnerId();
372
+ const dataDir = this.runner.config_.dataDir;
373
+
374
+ if (!runnerId || !dataDir) {
375
+ console.warn(
376
+ "[CodexManager] Missing runnerId or dataDir; cannot prepare Codex auth directory"
377
+ );
378
+ return null;
379
+ }
380
+
381
+ const codexDir = path.join(dataDir, "codex", runnerId);
382
+ const authPayload = {
383
+ OPENAI_API_KEY: null,
384
+ tokens: {
385
+ id_token: config.codexAuth.idToken,
386
+ access_token: config.codexAuth.accessToken,
387
+ refresh_token: "",
388
+ account_id: config.codexAuth.accountId,
389
+ },
390
+ last_refresh: config.codexAuth.lastRefresh || new Date().toISOString(),
391
+ };
392
+
393
+ try {
394
+ await fs.mkdir(codexDir, { recursive: true });
395
+ await fs.writeFile(
396
+ path.join(codexDir, "auth.json"),
397
+ JSON.stringify(authPayload, null, 2),
398
+ "utf-8"
399
+ );
400
+ return codexDir;
401
+ } catch (error) {
402
+ console.error(
403
+ "[CodexManager] Failed to persist Codex auth configuration:",
404
+ error
405
+ );
406
+ throw new Error(
407
+ "Runner failed to persist Codex credentials. Check runner logs for details."
408
+ );
409
+ }
410
+ }
411
+
412
+ private buildPromptForMessage(
413
+ context: ConversationContext,
414
+ text: string,
415
+ forceInstructions: boolean
416
+ ): string {
417
+ const trimmed = text ?? "";
418
+ const instructions = this.getInstructionPrefix(context);
419
+
420
+ const metadata =
421
+ (context.metadata as Record<string, any>) || ({} as Record<string, any>);
422
+ const shouldInjectInstructions =
423
+ forceInstructions || !metadata["instructionsInjected"];
424
+
425
+ if (instructions && shouldInjectInstructions) {
426
+ metadata["instructionsInjected"] = true;
427
+ context.metadata = metadata;
428
+ return `${instructions}\n\n${trimmed}`.trim();
429
+ }
430
+
431
+ return trimmed;
432
+ }
433
+
434
+ private getInstructionPrefix(context: ConversationContext): string {
435
+ const parts: string[] = [];
436
+
437
+ if (context.globalInstructions) {
438
+ parts.push(
439
+ `<global-instructions>\n${context.globalInstructions}\n</global-instructions>`
440
+ );
441
+ }
442
+
443
+ if (context.workspaceInstructions) {
444
+ parts.push(
445
+ `<workspace-instructions>\n${context.workspaceInstructions}\n</workspace-instructions>`
446
+ );
447
+ }
448
+
449
+ return parts.join("\n\n");
450
+ }
451
+
452
+ private launchTurn(context: ConversationContext, prompt: string): void {
453
+ const state = this.threadStates.get(context.conversationId);
454
+
455
+ if (!state || !state.thread) {
456
+ throw new Error(
457
+ `Thread state missing for conversation ${context.conversationId}`
458
+ );
459
+ }
460
+
461
+ const abortController = new AbortController();
462
+ state.abortController = abortController;
463
+
464
+ context.lastActivityAt = new Date();
465
+
466
+ const runPromise = this.streamThreadEvents(
467
+ context,
468
+ state.thread,
469
+ prompt,
470
+ abortController
471
+ );
472
+
473
+ state.runPromise = runPromise;
474
+
475
+ runPromise
476
+ .catch((error) => {
477
+ if (!this.isAbortError(error)) {
478
+ console.error(
479
+ `[CodexManager] Run failed for ${context.conversationId}:`,
480
+ error
481
+ );
482
+ this._handleConversationError(context, error as Error);
483
+ }
484
+ })
485
+ .finally(() => {
486
+ if (state.runPromise === runPromise) {
487
+ state.runPromise = null;
488
+ }
489
+ if (state.abortController === abortController) {
490
+ state.abortController = null;
491
+ }
492
+ });
493
+ }
494
+
495
+ private async streamThreadEvents(
496
+ context: ConversationContext,
497
+ thread: Thread,
498
+ prompt: string,
499
+ abortController: AbortController
500
+ ): Promise<void> {
501
+ try {
502
+ const { events } = await thread.runStreamed(prompt, {
503
+ signal: abortController.signal,
504
+ });
505
+
506
+ for await (const event of events) {
507
+ await this.handleThreadEvent(context, event);
508
+ }
509
+ } catch (error) {
510
+ if (this.isAbortError(error)) {
511
+ console.log(
512
+ `[CodexManager] Turn aborted for ${context.conversationId}`
513
+ );
514
+ return;
515
+ }
516
+
517
+ throw error;
518
+ }
519
+ }
520
+
521
+ private async abortActiveRun(conversationId: string): Promise<void> {
522
+ const state = this.threadStates.get(conversationId);
523
+ if (!state || !state.runPromise) return;
524
+
525
+ if (state.abortController) {
526
+ state.abortController.abort();
527
+ }
528
+
529
+ try {
530
+ await state.runPromise;
531
+ } catch (error) {
532
+ if (!this.isAbortError(error)) {
533
+ console.warn(
534
+ `[CodexManager] Run aborted with error for ${conversationId}:`,
535
+ error
536
+ );
537
+ }
538
+ }
539
+ }
540
+
541
+ private async handleThreadEvent(
542
+ context: ConversationContext,
543
+ event: ThreadEvent
544
+ ): Promise<void> {
545
+ try {
546
+ switch (event.type) {
547
+ case "thread.started": {
548
+ await this.handleThreadStarted(context, event.thread_id);
549
+ break;
550
+ }
551
+ case "turn.started": {
552
+ context.status = "active";
553
+ await this.sendAgentMessage(context, "system", {
554
+ subtype: "turn.started",
555
+ content: [
556
+ {
557
+ type: "text",
558
+ text: `Turn started at ${new Date().toISOString()}`,
559
+ },
560
+ ],
561
+ });
562
+ break;
563
+ }
564
+ case "turn.completed": {
565
+ await this.sendAgentMessage(context, "result", {
566
+ subtype: "turn.completed",
567
+ content: [
568
+ {
569
+ type: "usage",
570
+ usage: event.usage,
571
+ },
572
+ ],
573
+ });
574
+ context.status = "stopped";
575
+ await this._finalizeConversation(context, false);
576
+ break;
577
+ }
578
+ case "turn.failed": {
579
+ const error = new Error(event.error?.message || "Turn failed");
580
+ await this.sendAgentMessage(context, "error", {
581
+ subtype: "turn.failed",
582
+ content: [
583
+ {
584
+ type: "text",
585
+ text: error.message,
586
+ },
587
+ ],
588
+ isError: true,
589
+ });
590
+ await this._handleConversationError(context, error);
591
+ context.status = "stopped";
592
+ await this._finalizeConversation(context, true, error, "turn_failed");
593
+ break;
594
+ }
595
+ case "item.started":
596
+ case "item.updated":
597
+ case "item.completed": {
598
+ await this.forwardItemEvent(
599
+ context,
600
+ event.item,
601
+ event.type.split(".")[1] as "started" | "updated" | "completed"
602
+ );
603
+ break;
604
+ }
605
+ case "error": {
606
+ const fatalError = new Error(event.message || "Unknown error");
607
+ await this.sendAgentMessage(context, "error", {
608
+ subtype: "thread.error",
609
+ content: [{ type: "text", text: fatalError.message }],
610
+ isError: true,
611
+ });
612
+ await this._handleConversationError(context, fatalError);
613
+ context.status = "stopped";
614
+ await this._finalizeConversation(
615
+ context,
616
+ true,
617
+ fatalError,
618
+ "thread_error"
619
+ );
620
+ break;
621
+ }
622
+ }
623
+ } catch (error) {
624
+ console.error("[CodexManager] Failed to handle thread event", {
625
+ event,
626
+ error,
627
+ });
628
+ }
629
+ }
630
+
631
+ private async forwardItemEvent(
632
+ context: ConversationContext,
633
+ item: ThreadItem,
634
+ phase: "started" | "updated" | "completed"
635
+ ): Promise<void> {
636
+ const normalized = this.normalizeItemEvent(context, item, phase);
637
+ if (!normalized) return;
638
+
639
+ const { subtype, content, isError, toolCalls, type } = normalized;
640
+ const payload: {
641
+ subtype?: string;
642
+ content: any;
643
+ toolCalls?: any;
644
+ isError?: boolean;
645
+ } = {
646
+ subtype,
647
+ content,
648
+ isError,
649
+ };
650
+
651
+ if (toolCalls) {
652
+ payload.toolCalls = toolCalls;
653
+ }
654
+
655
+ await this.sendAgentMessage(context, type, payload);
656
+ }
657
+
658
+ private normalizeItemEvent(
659
+ context: ConversationContext,
660
+ item: ThreadItem,
661
+ phase: "started" | "updated" | "completed"
662
+ ): NormalizedItemEvent | null {
663
+ switch (item.type) {
664
+ case "agent_message": {
665
+ if (phase !== "completed") return null;
666
+ return {
667
+ type: "assistant",
668
+ content: [
669
+ {
670
+ type: "text",
671
+ text: item.text || "",
672
+ },
673
+ ],
674
+ };
675
+ }
676
+ case "reasoning": {
677
+ return {
678
+ type: "thinking",
679
+ content: [
680
+ {
681
+ type: "thinking",
682
+ thinking: item.text,
683
+ text: item.text,
684
+ phase,
685
+ },
686
+ ],
687
+ };
688
+ }
689
+ case "command_execution": {
690
+ return {
691
+ type: "system",
692
+ subtype: `command_execution.${phase}`,
693
+ content: [
694
+ {
695
+ type: "command_execution",
696
+ command: item.command,
697
+ aggregated_output: item.aggregated_output,
698
+ exit_code: item.exit_code,
699
+ status: item.status,
700
+ phase,
701
+ },
702
+ ],
703
+ };
704
+ }
705
+ case "file_change": {
706
+ if (phase === "updated") return null;
707
+ return {
708
+ type: "system",
709
+ subtype: "file_change",
710
+ content: [
711
+ {
712
+ type: "file_change",
713
+ changes: item.changes,
714
+ status: item.status,
715
+ },
716
+ ],
717
+ };
718
+ }
719
+ case "mcp_tool_call": {
720
+ const toolName = this.buildMcpToolName(item.server, item.tool);
721
+ const toolUseId = this.buildToolUseId(context, item.id);
722
+
723
+ if (item.status === "in_progress" || phase !== "completed") {
724
+ return {
725
+ type: "assistant",
726
+ subtype: "tool_use",
727
+ content: [
728
+ {
729
+ toolCalls: [
730
+ {
731
+ id: toolUseId,
732
+ name: toolName,
733
+ arguments: item.arguments,
734
+ server: item.server,
735
+ tool: item.tool,
736
+ status: item.status,
737
+ },
738
+ ],
739
+ timestamp: new Date().toISOString(),
740
+ },
741
+ ],
742
+ };
743
+ }
744
+
745
+ if (item.status === "failed") {
746
+ return {
747
+ type: "error",
748
+ subtype: "tool_result",
749
+ content: [
750
+ {
751
+ type: "text",
752
+ text: item.error?.message || "Tool call failed",
753
+ tool_use_id: toolUseId,
754
+ tool_name: toolName,
755
+ timestamp: new Date().toISOString(),
756
+ },
757
+ ],
758
+ isError: true,
759
+ };
760
+ }
761
+
762
+ return {
763
+ type: "tool_result",
764
+ subtype: "tool_result",
765
+ content: [
766
+ {
767
+ type: "tool_result",
768
+ subtype: "tool_result",
769
+ tool_use_id: toolUseId,
770
+ content: item.result?.content || [],
771
+ structured_content: item.result?.structured_content,
772
+ metadata: {
773
+ server: item.server,
774
+ tool: item.tool,
775
+ name: toolName,
776
+ original_tool_use_id: item.id,
777
+ },
778
+ timestamp: new Date().toISOString(),
779
+ },
780
+ ],
781
+ };
782
+ }
783
+ case "web_search": {
784
+ return {
785
+ type: "system",
786
+ subtype: `web_search.${phase}`,
787
+ content: [
788
+ {
789
+ type: "web_search",
790
+ query: item.query,
791
+ status: phase,
792
+ },
793
+ ],
794
+ };
795
+ }
796
+ case "todo_list": {
797
+ return {
798
+ type: "system",
799
+ subtype: "todo_list",
800
+ content: [
801
+ {
802
+ type: "todo_list",
803
+ items: item.items,
804
+ phase,
805
+ },
806
+ ],
807
+ };
808
+ }
809
+ case "error": {
810
+ return {
811
+ type: "error",
812
+ subtype: `item.${phase}`,
813
+ content: [
814
+ {
815
+ type: "text",
816
+ text: item.message,
817
+ },
818
+ ],
819
+ isError: true,
820
+ };
821
+ }
822
+ default:
823
+ return null;
824
+ }
825
+ }
826
+
827
+ private async sendAgentMessage(
828
+ context: ConversationContext,
829
+ type: string,
830
+ {
831
+ subtype,
832
+ content,
833
+ toolCalls,
834
+ isError,
835
+ }: {
836
+ subtype?: string;
837
+ content: any;
838
+ toolCalls?: any;
839
+ isError?: boolean;
840
+ }
841
+ ): Promise<void> {
842
+ const normalizedContent = Array.isArray(content)
843
+ ? content
844
+ : content
845
+ ? [content]
846
+ : [];
847
+
848
+ const payload = {
849
+ taskId: context.taskId,
850
+ conversationId: context.conversationId,
851
+ conversationObjectType: context.conversationObjectType,
852
+ conversationObjectId: context.conversationObjectId,
853
+ agentSessionId: context.agentSessionId,
854
+ type,
855
+ subtype,
856
+ content: normalizedContent,
857
+ toolCalls,
858
+ isError: Boolean(isError),
859
+ messageId: this.generateMessageId(context),
860
+ timestamp: new Date().toISOString(),
861
+ };
862
+
863
+ if (process.env["DEBUG"] === "true") {
864
+ console.log("[CodexManager] Sending message.agent", payload);
865
+ }
866
+
867
+ await this.runner.notify("message.agent", payload);
868
+ }
869
+
870
+ private async handleThreadStarted(
871
+ context: ConversationContext,
872
+ threadId: string
873
+ ): Promise<void> {
874
+ if (!threadId || threadId === context.agentSessionId) {
875
+ return;
876
+ }
877
+
878
+ const oldSessionId = context.agentSessionId;
879
+ context.agentSessionId = threadId;
880
+
881
+ await this.runner.notify("agentSessionId.changed", {
882
+ conversationId: context.conversationId,
883
+ conversationObjectType: context.conversationObjectType,
884
+ conversationObjectId: context.conversationObjectId,
885
+ oldAgentSessionId: oldSessionId,
886
+ newAgentSessionId: threadId,
887
+ });
888
+ }
889
+
890
+ private mapSandboxMode(
891
+ permissionsMode?: string
892
+ ): "read-only" | "workspace-write" | "danger-full-access" {
893
+ const mode = (permissionsMode || "").toLowerCase();
894
+ if (mode === "read" || mode === "read_only") {
895
+ return "read-only";
896
+ }
897
+ if (mode === "workspace" || mode === "workspace-write") {
898
+ return "workspace-write";
899
+ }
900
+ return "danger-full-access";
901
+ }
902
+
903
+ private shouldEnableNetwork(permissionsMode?: string): boolean {
904
+ const mode = (permissionsMode || "").toLowerCase();
905
+ return mode !== "read" && mode !== "read_only";
906
+ }
907
+
908
+ private getAdditionalDirectories(
909
+ config: ConversationConfig
910
+ ): string[] | undefined {
911
+ const additionalDirs: string[] = [];
912
+ if (config.runnerRepoPath) {
913
+ additionalDirs.push(config.runnerRepoPath);
914
+ }
915
+ return additionalDirs.length ? additionalDirs : undefined;
916
+ }
917
+
918
+ private buildConfigOverridesFromMcp(
919
+ mcpServers?: Record<string, any>
920
+ ): Record<string, unknown> | undefined {
921
+ if (!mcpServers) return undefined;
922
+ const overrides: Record<string, unknown> = {};
923
+
924
+ for (const [serverName, config] of Object.entries(mcpServers)) {
925
+ this.flattenOverrideObject(
926
+ `mcp_servers.${serverName}`,
927
+ config,
928
+ overrides
929
+ );
930
+ }
931
+
932
+ return Object.keys(overrides).length ? overrides : undefined;
933
+ }
934
+
935
+ private normalizeMcpServersForCodex(
936
+ mcpServers: Record<string, any>
937
+ ): Record<string, any> {
938
+ const normalized: Record<string, any> = {};
939
+ for (const [serverName, config] of Object.entries(mcpServers)) {
940
+ normalized[serverName] = this.stripAuthorizationHeader(config);
941
+ }
942
+ return normalized;
943
+ }
944
+
945
+ private buildMcpToolName(server?: string, tool?: string): string {
946
+ const safeServer = (server || "unknown").trim() || "unknown";
947
+ const safeTool = (tool || "unknown").trim() || "unknown";
948
+ return `mcp__${safeServer}__${safeTool}`;
949
+ }
950
+
951
+ private buildToolUseId(context: ConversationContext, rawId: string): string {
952
+ const scope =
953
+ context.agentSessionId ||
954
+ context.conversationId ||
955
+ context.conversationObjectId ||
956
+ "codex";
957
+ return `${scope}:${rawId}`;
958
+ }
959
+
960
+ private stripAuthorizationHeader(config: any): any {
961
+ if (!config || typeof config !== "object" || Array.isArray(config)) {
962
+ return config;
963
+ }
964
+
965
+ const normalized = { ...config };
966
+ if (
967
+ normalized.bearer_token_env_var &&
968
+ normalized.headers &&
969
+ typeof normalized.headers === "object" &&
970
+ !Array.isArray(normalized.headers)
971
+ ) {
972
+ const headers = { ...normalized.headers };
973
+ delete headers["Authorization"];
974
+ if (Object.keys(headers).length === 0) {
975
+ delete normalized.headers;
976
+ } else {
977
+ normalized.headers = headers;
978
+ }
979
+ }
980
+
981
+ return normalized;
982
+ }
983
+
984
+ private flattenOverrideObject(
985
+ prefix: string,
986
+ value: any,
987
+ target: Record<string, unknown>
988
+ ): void {
989
+ if (value === undefined) {
990
+ return;
991
+ }
992
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
993
+ target[prefix] = value;
994
+ return;
995
+ }
996
+
997
+ const entries = Object.entries(value);
998
+ if (!entries.length) {
999
+ target[prefix] = {};
1000
+ return;
1001
+ }
1002
+
1003
+ for (const [key, nested] of entries) {
1004
+ this.flattenOverrideObject(`${prefix}.${key}`, nested, target);
1005
+ }
1006
+ }
1007
+
1008
+ private async resolveWorkspacePath(
1009
+ context: ConversationContext,
1010
+ config: ConversationConfig
1011
+ ): Promise<string> {
1012
+ let workspacePath: string;
1013
+ const workspaceId = config.workspaceId;
1014
+
1015
+ if (config.runnerRepoPath) {
1016
+ workspacePath = config.runnerRepoPath;
1017
+ console.log(`[CodexManager] Using local workspace path ${workspacePath}`);
1018
+
1019
+ if (context.conversationObjectType === "Task") {
1020
+ const taskHandle = await this.repositoryManager.createLocalTaskHandle(
1021
+ context.conversationObjectId,
1022
+ workspacePath
1023
+ );
1024
+ (context as any).taskHandle = taskHandle;
1025
+ }
1026
+ return workspacePath;
1027
+ }
1028
+
1029
+ if (
1030
+ context.conversationObjectType === "Task" &&
1031
+ config.repository &&
1032
+ workspaceId
1033
+ ) {
1034
+ if (!config.repository.url) {
1035
+ throw new Error("Repository URL is required for task conversations");
1036
+ }
1037
+
1038
+ const taskHandle = await this.repositoryManager.createTaskWorktree(
1039
+ context.conversationObjectId,
1040
+ workspaceId,
1041
+ config.repository.url,
1042
+ config.repository.branch,
1043
+ config.githubToken
1044
+ );
1045
+ (context as any).taskHandle = taskHandle;
1046
+ workspacePath = taskHandle.worktreePath;
1047
+ return workspacePath;
1048
+ }
1049
+
1050
+ if (config.repository && workspaceId) {
1051
+ if (config.repository.type === "local" && config.repository.localPath) {
1052
+ workspacePath = config.repository.localPath;
1053
+ return workspacePath;
1054
+ }
1055
+
1056
+ workspacePath = await this.repositoryManager.checkoutRepository(
1057
+ workspaceId,
1058
+ config.repository.url,
1059
+ config.repository.branch,
1060
+ config.githubToken
1061
+ );
1062
+ return workspacePath;
1063
+ }
1064
+
1065
+ if (workspaceId) {
1066
+ workspacePath =
1067
+ await this.repositoryManager.getWorkspacePath(workspaceId);
1068
+ return workspacePath;
1069
+ }
1070
+
1071
+ return process.cwd();
1072
+ }
1073
+
1074
+ private async fetchGithubTokens(
1075
+ workspaceId: string
1076
+ ): Promise<string | undefined> {
1077
+ try {
1078
+ const response = await fetch(
1079
+ `${this.runner.config_.orchestratorUrl}/api/runner/tokens?workspaceId=${workspaceId}`,
1080
+ {
1081
+ method: "GET",
1082
+ headers: {
1083
+ Authorization: `Bearer ${process.env["NORTHFLARE_RUNNER_TOKEN"]}`,
1084
+ },
1085
+ }
1086
+ );
1087
+
1088
+ if (!response.ok) {
1089
+ console.error(
1090
+ `[CodexManager] Failed to fetch GitHub tokens: ${response.status}`
1091
+ );
1092
+ return undefined;
1093
+ }
1094
+
1095
+ const data = (await response.json()) as { githubToken?: string };
1096
+ return data.githubToken;
1097
+ } catch (error) {
1098
+ console.error("[CodexManager] Error fetching GitHub tokens", error);
1099
+ return undefined;
1100
+ }
1101
+ }
1102
+
1103
+ private generateToolToken(context: ConversationContext): string | undefined {
1104
+ if (!context.config.mcpServers) return undefined;
1105
+
1106
+ const runnerToken = process.env["NORTHFLARE_RUNNER_TOKEN"];
1107
+ const runnerUid = this.runner.getRunnerUid();
1108
+
1109
+ if (!runnerToken || !runnerUid) return undefined;
1110
+
1111
+ return jwt.sign(
1112
+ {
1113
+ conversationId: context.conversationId,
1114
+ runnerUid,
1115
+ },
1116
+ runnerToken,
1117
+ {
1118
+ expiresIn: "60m",
1119
+ }
1120
+ );
1121
+ }
1122
+
1123
+ private normalizeToText(value: any): string {
1124
+ if (typeof value === "string") return value;
1125
+ if (value == null) return "";
1126
+
1127
+ if (typeof value === "object") {
1128
+ if (typeof (value as any).text === "string") {
1129
+ return (value as any).text;
1130
+ }
1131
+ if (Array.isArray(value)) {
1132
+ const texts = value
1133
+ .map((entry) => {
1134
+ if (
1135
+ entry &&
1136
+ typeof entry === "object" &&
1137
+ typeof entry.text === "string"
1138
+ ) {
1139
+ return entry.text;
1140
+ }
1141
+ return null;
1142
+ })
1143
+ .filter((entry): entry is string => Boolean(entry));
1144
+ if (texts.length) {
1145
+ return texts.join(" ");
1146
+ }
1147
+ }
1148
+ if (typeof (value as any).content === "string") {
1149
+ return (value as any).content;
1150
+ }
1151
+ }
1152
+
1153
+ try {
1154
+ return JSON.stringify(value);
1155
+ } catch {
1156
+ return String(value);
1157
+ }
1158
+ }
1159
+
1160
+ private normalizeSessionId(value?: string | null): string | undefined {
1161
+ if (!value) return undefined;
1162
+ const trimmed = value.trim();
1163
+ return trimmed.length ? trimmed : undefined;
1164
+ }
1165
+
1166
+ private resolveSessionId(
1167
+ config?: ConversationConfig,
1168
+ conversationData?: ConversationDetails,
1169
+ override?: string | null
1170
+ ): string {
1171
+ return (
1172
+ this.normalizeSessionId(override) ||
1173
+ this.normalizeSessionId(config?.sessionId) ||
1174
+ this.normalizeSessionId(conversationData?.agentSessionId) ||
1175
+ this.normalizeSessionId((conversationData as any)?.threadId) ||
1176
+ this.normalizeSessionId((conversationData as any)?.sessionId) ||
1177
+ ""
1178
+ );
1179
+ }
1180
+
1181
+ private isAbortError(error: unknown): boolean {
1182
+ if (!error) return false;
1183
+ return (
1184
+ (error as any).name === "AbortError" ||
1185
+ /aborted|abort/i.test((error as any).message || "")
1186
+ );
1187
+ }
1188
+
1189
+ private async _finalizeConversation(
1190
+ context: ConversationContext,
1191
+ hadError: boolean,
1192
+ error?: any,
1193
+ reason?: string
1194
+ ): Promise<void> {
1195
+ if ((context as any)._finalized) return;
1196
+ (context as any)._finalized = true;
1197
+
1198
+ try {
1199
+ await this.runner.notify("conversation.end", {
1200
+ conversationId: context.conversationId,
1201
+ conversationObjectType: context.conversationObjectType,
1202
+ conversationObjectId: context.conversationObjectId,
1203
+ agentSessionId: context.agentSessionId,
1204
+ isError: hadError,
1205
+ errorMessage: error?.message,
1206
+ reason,
1207
+ });
1208
+ } catch (notifyError) {
1209
+ console.error(
1210
+ "[CodexManager] Failed to send conversation.end notification",
1211
+ notifyError
1212
+ );
1213
+ }
1214
+
1215
+ this.threadStates.delete(context.conversationId);
1216
+ this.runner.activeConversations_.delete(context.conversationId);
1217
+ statusLineManager.updateActiveCount(this.runner.activeConversations_.size);
1218
+ }
1219
+
1220
+ private async _handleConversationError(
1221
+ context: ConversationContext,
1222
+ error: Error
1223
+ ): Promise<void> {
1224
+ console.error(
1225
+ `[CodexManager] Conversation error for ${context.conversationId}:`,
1226
+ error
1227
+ );
1228
+
1229
+ await this.runner.notify("error.report", {
1230
+ conversationId: context.conversationId,
1231
+ conversationObjectType: context.conversationObjectType,
1232
+ conversationObjectId: context.conversationObjectId,
1233
+ agentSessionId: context.agentSessionId,
1234
+ errorType: "codex_error",
1235
+ message: error.message,
1236
+ details: {
1237
+ stack: error.stack,
1238
+ timestamp: new Date(),
1239
+ },
1240
+ });
1241
+ }
1242
+
1243
+ private generateMessageId(context: ConversationContext): string {
1244
+ return `${context.agentSessionId}-${Date.now()}-${Math.random()
1245
+ .toString(36)
1246
+ .slice(2)}`;
1247
+ }
1248
+ }