@zhang6hk/dsh-codex 0.0.0-stage → 0.1.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 (37) hide show
  1. package/LICENSE +201 -0
  2. package/NOTICE +18 -0
  3. package/README.md +75 -2
  4. package/README.zh.md +78 -0
  5. package/cordis.patch.yml +7 -0
  6. package/lib/args.js +118 -0
  7. package/lib/codex-path.js +31 -0
  8. package/lib/commands.js +242 -0
  9. package/lib/companion.js +177 -0
  10. package/lib/index.js +105 -0
  11. package/lib/render.js +114 -0
  12. package/lib/tool.js +103 -0
  13. package/locale/en.json +6 -0
  14. package/locale/zh.json +6 -0
  15. package/package.json +49 -4
  16. package/vendor/codex/.claude-plugin/plugin.json +8 -0
  17. package/vendor/codex/prompts/adversarial-review.md +84 -0
  18. package/vendor/codex/prompts/stop-review-gate.md +36 -0
  19. package/vendor/codex/schemas/review-output.schema.json +87 -0
  20. package/vendor/codex/scripts/app-server-broker.mjs +252 -0
  21. package/vendor/codex/scripts/codex-companion.mjs +1073 -0
  22. package/vendor/codex/scripts/lib/app-server-protocol.d.ts +75 -0
  23. package/vendor/codex/scripts/lib/app-server.mjs +354 -0
  24. package/vendor/codex/scripts/lib/args.mjs +128 -0
  25. package/vendor/codex/scripts/lib/broker-endpoint.mjs +41 -0
  26. package/vendor/codex/scripts/lib/broker-lifecycle.mjs +209 -0
  27. package/vendor/codex/scripts/lib/claude-session-transfer.mjs +44 -0
  28. package/vendor/codex/scripts/lib/codex.mjs +1219 -0
  29. package/vendor/codex/scripts/lib/fs.mjs +40 -0
  30. package/vendor/codex/scripts/lib/git.mjs +347 -0
  31. package/vendor/codex/scripts/lib/job-control.mjs +308 -0
  32. package/vendor/codex/scripts/lib/process.mjs +135 -0
  33. package/vendor/codex/scripts/lib/prompts.mjs +13 -0
  34. package/vendor/codex/scripts/lib/render.mjs +465 -0
  35. package/vendor/codex/scripts/lib/state.mjs +191 -0
  36. package/vendor/codex/scripts/lib/tracked-jobs.mjs +204 -0
  37. package/vendor/codex/scripts/lib/workspace.mjs +9 -0
@@ -0,0 +1,1219 @@
1
+ /**
2
+ * @typedef {import("./app-server-protocol").AppServerNotification} AppServerNotification
3
+ * @typedef {import("./app-server-protocol").ReviewTarget} ReviewTarget
4
+ * @typedef {import("./app-server-protocol").ThreadItem} ThreadItem
5
+ * @typedef {import("./app-server-protocol").ThreadResumeParams} ThreadResumeParams
6
+ * @typedef {import("./app-server-protocol").ThreadStartParams} ThreadStartParams
7
+ * @typedef {import("./app-server-protocol").Turn} Turn
8
+ * @typedef {import("./app-server-protocol").UserInput} UserInput
9
+ * @typedef {((update: string | { message: string, phase: string | null, threadId?: string | null, turnId?: string | null, stderrMessage?: string | null, logTitle?: string | null, logBody?: string | null }) => void)} ProgressReporter
10
+ * @typedef {{
11
+ * threadId: string,
12
+ * rootThreadId: string,
13
+ * threadIds: Set<string>,
14
+ * threadTurnIds: Map<string, string>,
15
+ * threadLabels: Map<string, string>,
16
+ * turnId: string | null,
17
+ * bufferedNotifications: AppServerNotification[],
18
+ * completion: Promise<TurnCaptureState>,
19
+ * resolveCompletion: (state: TurnCaptureState) => void,
20
+ * rejectCompletion: (error: unknown) => void,
21
+ * finalTurn: Turn | null,
22
+ * completed: boolean,
23
+ * finalAnswerSeen: boolean,
24
+ * pendingCollaborations: Set<string>,
25
+ * activeSubagentTurns: Set<string>,
26
+ * completionTimer: ReturnType<typeof setTimeout> | null,
27
+ * lastAgentMessage: string,
28
+ * reviewText: string,
29
+ * reasoningSummary: string[],
30
+ * error: unknown,
31
+ * messages: Array<{ lifecycle: string, phase: string | null, text: string }>,
32
+ * fileChanges: ThreadItem[],
33
+ * commandExecutions: ThreadItem[],
34
+ * onProgress: ProgressReporter | null
35
+ * }} TurnCaptureState
36
+ */
37
+ import crypto from "node:crypto";
38
+ import fs from "node:fs";
39
+ import os from "node:os";
40
+ import path from "node:path";
41
+
42
+ import { readJsonFile } from "./fs.mjs";
43
+ import { BROKER_BUSY_RPC_CODE, BROKER_ENDPOINT_ENV, CodexAppServerClient } from "./app-server.mjs";
44
+ import { loadBrokerSession } from "./broker-lifecycle.mjs";
45
+ import { binaryAvailable } from "./process.mjs";
46
+
47
+ const SERVICE_NAME = "claude_code_codex_plugin";
48
+ const TASK_THREAD_PREFIX = "Codex Companion Task";
49
+ const DEFAULT_CONTINUE_PROMPT =
50
+ "Continue from the current thread state. Pick the next highest-value step and follow through until the task is resolved.";
51
+ const EXTERNAL_AGENT_IMPORT_COMPLETED = "externalAgentConfig/import/completed";
52
+ const EXTERNAL_AGENT_IMPORT_TIMEOUT_MS = 2 * 60 * 1000;
53
+
54
+ function cleanCodexStderr(stderr) {
55
+ return stderr
56
+ .split(/\r?\n/)
57
+ .map((line) => line.trimEnd())
58
+ .filter((line) => line && !line.startsWith("WARNING: proceeding, even though we could not update PATH:"))
59
+ .join("\n");
60
+ }
61
+
62
+ /** @returns {ThreadStartParams} */
63
+ function buildThreadParams(cwd, options = {}) {
64
+ return {
65
+ cwd,
66
+ model: options.model ?? null,
67
+ approvalPolicy: options.approvalPolicy ?? "never",
68
+ sandbox: options.sandbox ?? "read-only",
69
+ serviceName: SERVICE_NAME,
70
+ ephemeral: options.ephemeral ?? true
71
+ };
72
+ }
73
+
74
+ /** @returns {ThreadResumeParams} */
75
+ function buildResumeParams(threadId, cwd, options = {}) {
76
+ return {
77
+ threadId,
78
+ cwd,
79
+ model: options.model ?? null,
80
+ approvalPolicy: options.approvalPolicy ?? "never",
81
+ sandbox: options.sandbox ?? "read-only"
82
+ };
83
+ }
84
+
85
+ /** @returns {UserInput[]} */
86
+ function buildTurnInput(prompt) {
87
+ return [{ type: "text", text: prompt, text_elements: [] }];
88
+ }
89
+
90
+ function shorten(text, limit = 72) {
91
+ const normalized = String(text ?? "").trim().replace(/\s+/g, " ");
92
+ if (!normalized) {
93
+ return "";
94
+ }
95
+ if (normalized.length <= limit) {
96
+ return normalized;
97
+ }
98
+ return `${normalized.slice(0, limit - 3)}...`;
99
+ }
100
+
101
+ function looksLikeVerificationCommand(command) {
102
+ return /\b(test|tests|lint|build|typecheck|type-check|check|verify|validate|pytest|jest|vitest|cargo test|npm test|pnpm test|yarn test|go test|mvn test|gradle test|tsc|eslint|ruff)\b/i.test(
103
+ command
104
+ );
105
+ }
106
+
107
+ function buildTaskThreadName(prompt) {
108
+ const excerpt = shorten(prompt, 56);
109
+ return excerpt ? `${TASK_THREAD_PREFIX}: ${excerpt}` : TASK_THREAD_PREFIX;
110
+ }
111
+
112
+ function extractThreadId(message) {
113
+ return message?.params?.threadId ?? null;
114
+ }
115
+
116
+ function extractTurnId(message) {
117
+ if (message?.params?.turnId) {
118
+ return message.params.turnId;
119
+ }
120
+ if (message?.params?.turn?.id) {
121
+ return message.params.turn.id;
122
+ }
123
+ return null;
124
+ }
125
+
126
+ function collectTouchedFiles(fileChanges) {
127
+ const paths = new Set();
128
+ for (const fileChange of fileChanges) {
129
+ for (const change of fileChange.changes ?? []) {
130
+ if (change.path) {
131
+ paths.add(change.path);
132
+ }
133
+ }
134
+ }
135
+ return [...paths];
136
+ }
137
+
138
+ function normalizeReasoningText(text) {
139
+ return String(text ?? "").replace(/\s+/g, " ").trim();
140
+ }
141
+
142
+ function extractReasoningSections(value) {
143
+ if (!value) {
144
+ return [];
145
+ }
146
+
147
+ if (typeof value === "string") {
148
+ const normalized = normalizeReasoningText(value);
149
+ return normalized ? [normalized] : [];
150
+ }
151
+
152
+ if (Array.isArray(value)) {
153
+ return value.flatMap((entry) => extractReasoningSections(entry));
154
+ }
155
+
156
+ if (typeof value === "object") {
157
+ if (typeof value.text === "string") {
158
+ return extractReasoningSections(value.text);
159
+ }
160
+ if ("summary" in value) {
161
+ return extractReasoningSections(value.summary);
162
+ }
163
+ if ("content" in value) {
164
+ return extractReasoningSections(value.content);
165
+ }
166
+ if ("parts" in value) {
167
+ return extractReasoningSections(value.parts);
168
+ }
169
+ }
170
+
171
+ return [];
172
+ }
173
+
174
+ function mergeReasoningSections(existingSections, nextSections) {
175
+ const merged = [];
176
+ for (const section of [...existingSections, ...nextSections]) {
177
+ const normalized = normalizeReasoningText(section);
178
+ if (!normalized || merged.includes(normalized)) {
179
+ continue;
180
+ }
181
+ merged.push(normalized);
182
+ }
183
+ return merged;
184
+ }
185
+
186
+ /**
187
+ * @param {ProgressReporter | null | undefined} onProgress
188
+ * @param {string | null | undefined} message
189
+ * @param {string | null | undefined} [phase]
190
+ */
191
+ function emitProgress(onProgress, message, phase = null, extra = {}) {
192
+ if (!onProgress || !message) {
193
+ return;
194
+ }
195
+ if (!phase && Object.keys(extra).length === 0) {
196
+ onProgress(message);
197
+ return;
198
+ }
199
+ onProgress({ message, phase, ...extra });
200
+ }
201
+
202
+ function emitLogEvent(onProgress, options = {}) {
203
+ if (!onProgress) {
204
+ return;
205
+ }
206
+
207
+ onProgress({
208
+ message: options.message ?? "",
209
+ phase: options.phase ?? null,
210
+ stderrMessage: options.stderrMessage ?? null,
211
+ logTitle: options.logTitle ?? null,
212
+ logBody: options.logBody ?? null
213
+ });
214
+ }
215
+
216
+ function labelForThread(state, threadId) {
217
+ if (!threadId || threadId === state.rootThreadId || threadId === state.threadId) {
218
+ return null;
219
+ }
220
+ return state.threadLabels.get(threadId) ?? threadId;
221
+ }
222
+
223
+ function registerThread(state, threadId, options = {}) {
224
+ if (!threadId) {
225
+ return;
226
+ }
227
+
228
+ state.threadIds.add(threadId);
229
+ const label =
230
+ options.threadName ??
231
+ options.name ??
232
+ options.agentNickname ??
233
+ options.agentRole ??
234
+ state.threadLabels.get(threadId) ??
235
+ null;
236
+ if (label) {
237
+ state.threadLabels.set(threadId, label);
238
+ }
239
+ }
240
+
241
+ function describeStartedItem(state, item) {
242
+ switch (item.type) {
243
+ case "enteredReviewMode":
244
+ return { message: `Reviewer started: ${item.review}`, phase: "reviewing" };
245
+ case "commandExecution":
246
+ return {
247
+ message: `Running command: ${shorten(item.command, 96)}`,
248
+ phase: looksLikeVerificationCommand(item.command) ? "verifying" : "running"
249
+ };
250
+ case "fileChange":
251
+ return { message: `Applying ${item.changes.length} file change(s).`, phase: "editing" };
252
+ case "mcpToolCall":
253
+ return { message: `Calling ${item.server}/${item.tool}.`, phase: "investigating" };
254
+ case "dynamicToolCall":
255
+ return { message: `Running tool: ${item.tool}.`, phase: "investigating" };
256
+ case "collabAgentToolCall": {
257
+ const subagents = (item.receiverThreadIds ?? []).map((threadId) => labelForThread(state, threadId) ?? threadId);
258
+ const summary =
259
+ subagents.length > 0
260
+ ? `Starting subagent ${subagents.join(", ")} via collaboration tool: ${item.tool}.`
261
+ : `Starting collaboration tool: ${item.tool}.`;
262
+ return { message: summary, phase: "investigating" };
263
+ }
264
+ case "webSearch":
265
+ return { message: `Searching: ${shorten(item.query, 96)}`, phase: "investigating" };
266
+ default:
267
+ return null;
268
+ }
269
+ }
270
+
271
+ function describeCompletedItem(state, item) {
272
+ switch (item.type) {
273
+ case "commandExecution": {
274
+ const exitCode = item.exitCode ?? "?";
275
+ const statusLabel = item.status === "completed" ? "completed" : item.status;
276
+ return {
277
+ message: `Command ${statusLabel}: ${shorten(item.command, 96)} (exit ${exitCode})`,
278
+ phase: looksLikeVerificationCommand(item.command) ? "verifying" : "running"
279
+ };
280
+ }
281
+ case "fileChange":
282
+ return { message: `File changes ${item.status}.`, phase: "editing" };
283
+ case "mcpToolCall":
284
+ return { message: `Tool ${item.server}/${item.tool} ${item.status}.`, phase: "investigating" };
285
+ case "dynamicToolCall":
286
+ return { message: `Tool ${item.tool} ${item.status}.`, phase: "investigating" };
287
+ case "collabAgentToolCall": {
288
+ const subagents = (item.receiverThreadIds ?? []).map((threadId) => labelForThread(state, threadId) ?? threadId);
289
+ const summary =
290
+ subagents.length > 0
291
+ ? `Subagent ${subagents.join(", ")} ${item.status}.`
292
+ : `Collaboration tool ${item.tool} ${item.status}.`;
293
+ return { message: summary, phase: "investigating" };
294
+ }
295
+ case "exitedReviewMode":
296
+ return { message: "Reviewer finished.", phase: "finalizing" };
297
+ default:
298
+ return null;
299
+ }
300
+ }
301
+
302
+ /** @returns {TurnCaptureState} */
303
+ function createTurnCaptureState(threadId, options = {}) {
304
+ let resolveCompletion;
305
+ let rejectCompletion;
306
+ const completion = new Promise((resolve, reject) => {
307
+ resolveCompletion = resolve;
308
+ rejectCompletion = reject;
309
+ });
310
+
311
+ return {
312
+ threadId,
313
+ rootThreadId: threadId,
314
+ threadIds: new Set([threadId]),
315
+ threadTurnIds: new Map(),
316
+ threadLabels: new Map(),
317
+ turnId: null,
318
+ bufferedNotifications: [],
319
+ completion,
320
+ resolveCompletion,
321
+ rejectCompletion,
322
+ finalTurn: null,
323
+ completed: false,
324
+ finalAnswerSeen: false,
325
+ pendingCollaborations: new Set(),
326
+ activeSubagentTurns: new Set(),
327
+ completionTimer: null,
328
+ lastAgentMessage: "",
329
+ reviewText: "",
330
+ reasoningSummary: [],
331
+ error: null,
332
+ messages: [],
333
+ fileChanges: [],
334
+ commandExecutions: [],
335
+ onProgress: options.onProgress ?? null
336
+ };
337
+ }
338
+
339
+ function clearCompletionTimer(state) {
340
+ if (state.completionTimer) {
341
+ clearTimeout(state.completionTimer);
342
+ state.completionTimer = null;
343
+ }
344
+ }
345
+
346
+ function completeTurn(state, turn = null, options = {}) {
347
+ if (state.completed) {
348
+ return;
349
+ }
350
+
351
+ clearCompletionTimer(state);
352
+ state.completed = true;
353
+
354
+ if (turn) {
355
+ state.finalTurn = turn;
356
+ if (!state.turnId) {
357
+ state.turnId = turn.id;
358
+ }
359
+ } else if (!state.finalTurn) {
360
+ state.finalTurn = {
361
+ id: state.turnId ?? "inferred-turn",
362
+ status: "completed"
363
+ };
364
+ }
365
+
366
+ if (options.inferred) {
367
+ emitProgress(state.onProgress, "Turn completion inferred after the main thread finished and subagent work drained.", "finalizing");
368
+ }
369
+
370
+ state.resolveCompletion(state);
371
+ }
372
+
373
+ function scheduleInferredCompletion(state) {
374
+ if (state.completed || state.finalTurn || !state.finalAnswerSeen) {
375
+ return;
376
+ }
377
+
378
+ if (state.pendingCollaborations.size > 0 || state.activeSubagentTurns.size > 0) {
379
+ return;
380
+ }
381
+
382
+ clearCompletionTimer(state);
383
+ state.completionTimer = setTimeout(() => {
384
+ state.completionTimer = null;
385
+ if (state.completed || state.finalTurn || !state.finalAnswerSeen) {
386
+ return;
387
+ }
388
+ if (state.pendingCollaborations.size > 0 || state.activeSubagentTurns.size > 0) {
389
+ return;
390
+ }
391
+ completeTurn(state, null, { inferred: true });
392
+ }, 250);
393
+ state.completionTimer.unref?.();
394
+ }
395
+
396
+ function belongsToTurn(state, message) {
397
+ const messageThreadId = extractThreadId(message);
398
+ if (!messageThreadId || !state.threadIds.has(messageThreadId)) {
399
+ return false;
400
+ }
401
+ const trackedTurnId = state.threadTurnIds.get(messageThreadId) ?? null;
402
+ const messageTurnId = extractTurnId(message);
403
+ return trackedTurnId === null || messageTurnId === null || messageTurnId === trackedTurnId;
404
+ }
405
+
406
+ function recordItem(state, item, lifecycle, threadId = null) {
407
+ if (item.type === "collabAgentToolCall") {
408
+ if (!threadId || threadId === state.threadId) {
409
+ if (lifecycle === "started" || item.status === "inProgress") {
410
+ state.pendingCollaborations.add(item.id);
411
+ } else if (lifecycle === "completed") {
412
+ state.pendingCollaborations.delete(item.id);
413
+ scheduleInferredCompletion(state);
414
+ }
415
+ }
416
+ for (const receiverThreadId of item.receiverThreadIds ?? []) {
417
+ registerThread(state, receiverThreadId);
418
+ }
419
+ }
420
+
421
+ if (item.type === "agentMessage") {
422
+ state.messages.push({
423
+ lifecycle,
424
+ phase: item.phase ?? null,
425
+ text: item.text ?? ""
426
+ });
427
+ if (item.text) {
428
+ if (!threadId || threadId === state.threadId) {
429
+ state.lastAgentMessage = item.text;
430
+ if (lifecycle === "completed" && item.phase === "final_answer") {
431
+ state.finalAnswerSeen = true;
432
+ scheduleInferredCompletion(state);
433
+ }
434
+ }
435
+ if (lifecycle === "completed") {
436
+ const sourceLabel = labelForThread(state, threadId);
437
+ emitLogEvent(state.onProgress, {
438
+ message: sourceLabel ? `Subagent ${sourceLabel}: ${shorten(item.text, 96)}` : `Assistant message captured: ${shorten(item.text, 96)}`,
439
+ stderrMessage: null,
440
+ phase: item.phase === "final_answer" ? "finalizing" : null,
441
+ logTitle: sourceLabel ? `Subagent ${sourceLabel} message` : "Assistant message",
442
+ logBody: item.text
443
+ });
444
+ }
445
+ }
446
+ return;
447
+ }
448
+
449
+ if (item.type === "exitedReviewMode") {
450
+ state.reviewText = item.review ?? "";
451
+ if (lifecycle === "completed" && item.review) {
452
+ emitLogEvent(state.onProgress, {
453
+ message: "Review output captured.",
454
+ stderrMessage: null,
455
+ phase: "finalizing",
456
+ logTitle: "Review output",
457
+ logBody: item.review
458
+ });
459
+ }
460
+ return;
461
+ }
462
+
463
+ if (item.type === "reasoning" && lifecycle === "completed") {
464
+ const nextSections = extractReasoningSections(item.summary);
465
+ state.reasoningSummary = mergeReasoningSections(state.reasoningSummary, nextSections);
466
+ if (nextSections.length > 0) {
467
+ const sourceLabel = labelForThread(state, threadId);
468
+ emitLogEvent(state.onProgress, {
469
+ message: sourceLabel
470
+ ? `Subagent ${sourceLabel} reasoning: ${shorten(nextSections[0], 96)}`
471
+ : `Reasoning summary captured: ${shorten(nextSections[0], 96)}`,
472
+ stderrMessage: null,
473
+ logTitle: sourceLabel ? `Subagent ${sourceLabel} reasoning summary` : "Reasoning summary",
474
+ logBody: nextSections.map((section) => `- ${section}`).join("\n")
475
+ });
476
+ }
477
+ return;
478
+ }
479
+
480
+ if (item.type === "fileChange" && lifecycle === "completed") {
481
+ state.fileChanges.push(item);
482
+ return;
483
+ }
484
+
485
+ if (item.type === "commandExecution" && lifecycle === "completed") {
486
+ state.commandExecutions.push(item);
487
+ }
488
+ }
489
+
490
+ function applyTurnNotification(state, message) {
491
+ switch (message.method) {
492
+ case "thread/started":
493
+ registerThread(state, message.params.thread.id, {
494
+ threadName: message.params.thread.name,
495
+ name: message.params.thread.name,
496
+ agentNickname: message.params.thread.agentNickname,
497
+ agentRole: message.params.thread.agentRole
498
+ });
499
+ break;
500
+ case "thread/name/updated":
501
+ registerThread(state, message.params.threadId, {
502
+ threadName: message.params.threadName ?? null
503
+ });
504
+ break;
505
+ case "turn/started":
506
+ registerThread(state, message.params.threadId);
507
+ state.threadTurnIds.set(message.params.threadId, message.params.turn.id);
508
+ if ((message.params.threadId ?? null) !== state.threadId) {
509
+ state.activeSubagentTurns.add(message.params.threadId);
510
+ }
511
+ emitProgress(
512
+ state.onProgress,
513
+ `Turn started (${message.params.turn.id}).`,
514
+ "starting",
515
+ (message.params.threadId ?? null) === state.threadId
516
+ ? {
517
+ threadId: message.params.threadId ?? null,
518
+ turnId: message.params.turn.id ?? null
519
+ }
520
+ : {}
521
+ );
522
+ break;
523
+ case "item/started":
524
+ recordItem(state, message.params.item, "started", message.params.threadId ?? null);
525
+ {
526
+ const update = describeStartedItem(state, message.params.item);
527
+ emitProgress(state.onProgress, update?.message, update?.phase ?? null);
528
+ }
529
+ break;
530
+ case "item/completed":
531
+ recordItem(state, message.params.item, "completed", message.params.threadId ?? null);
532
+ {
533
+ const update = describeCompletedItem(state, message.params.item);
534
+ emitProgress(state.onProgress, update?.message, update?.phase ?? null);
535
+ }
536
+ break;
537
+ case "error":
538
+ state.error = message.params.error;
539
+ emitProgress(state.onProgress, `Codex error: ${message.params.error.message}`, "failed");
540
+ break;
541
+ case "turn/completed":
542
+ if ((message.params.threadId ?? null) !== state.threadId) {
543
+ state.activeSubagentTurns.delete(message.params.threadId);
544
+ scheduleInferredCompletion(state);
545
+ break;
546
+ }
547
+ emitProgress(
548
+ state.onProgress,
549
+ `Turn ${message.params.turn.status === "completed" ? "completed" : message.params.turn.status}.`,
550
+ "finalizing"
551
+ );
552
+ completeTurn(state, message.params.turn);
553
+ break;
554
+ default:
555
+ break;
556
+ }
557
+ }
558
+
559
+ async function captureTurn(client, threadId, startRequest, options = {}) {
560
+ const state = createTurnCaptureState(threadId, options);
561
+ const previousHandler = client.notificationHandler;
562
+
563
+ client.setNotificationHandler((message) => {
564
+ if (!state.turnId) {
565
+ state.bufferedNotifications.push(message);
566
+ return;
567
+ }
568
+
569
+ if (message.method === "thread/started" || message.method === "thread/name/updated") {
570
+ applyTurnNotification(state, message);
571
+ return;
572
+ }
573
+
574
+ if (!belongsToTurn(state, message)) {
575
+ if (previousHandler) {
576
+ previousHandler(message);
577
+ }
578
+ return;
579
+ }
580
+
581
+ applyTurnNotification(state, message);
582
+ });
583
+
584
+ try {
585
+ const response = await startRequest();
586
+ options.onResponse?.(response, state);
587
+ state.turnId = response.turn?.id ?? null;
588
+ if (state.turnId) {
589
+ state.threadTurnIds.set(state.threadId, state.turnId);
590
+ }
591
+ for (const message of state.bufferedNotifications) {
592
+ if (belongsToTurn(state, message)) {
593
+ applyTurnNotification(state, message);
594
+ } else {
595
+ if (previousHandler) {
596
+ previousHandler(message);
597
+ }
598
+ }
599
+ }
600
+ state.bufferedNotifications.length = 0;
601
+
602
+ if (response.turn?.status && response.turn.status !== "inProgress") {
603
+ completeTurn(state, response.turn);
604
+ }
605
+
606
+ return await state.completion;
607
+ } finally {
608
+ clearCompletionTimer(state);
609
+ client.setNotificationHandler(previousHandler ?? null);
610
+ }
611
+ }
612
+
613
+ async function withAppServer(cwd, fn) {
614
+ let client = null;
615
+ try {
616
+ client = await CodexAppServerClient.connect(cwd);
617
+ const result = await fn(client);
618
+ await client.close();
619
+ return result;
620
+ } catch (error) {
621
+ const brokerRequested = client?.transport === "broker" || Boolean(process.env[BROKER_ENDPOINT_ENV]);
622
+ const shouldRetryDirect =
623
+ (client?.transport === "broker" && error?.rpcCode === BROKER_BUSY_RPC_CODE) ||
624
+ (brokerRequested && (error?.code === "ENOENT" || error?.code === "ECONNREFUSED"));
625
+
626
+ if (client) {
627
+ await client.close().catch(() => {});
628
+ client = null;
629
+ }
630
+
631
+ if (!shouldRetryDirect) {
632
+ throw error;
633
+ }
634
+
635
+ const directClient = await CodexAppServerClient.connect(cwd, { disableBroker: true });
636
+ try {
637
+ return await fn(directClient);
638
+ } finally {
639
+ await directClient.close();
640
+ }
641
+ }
642
+ }
643
+
644
+ async function withDirectAppServer(cwd, fn) {
645
+ const client = await CodexAppServerClient.connect(cwd, { disableBroker: true });
646
+ try {
647
+ return await fn(client);
648
+ } finally {
649
+ await client.close();
650
+ }
651
+ }
652
+
653
+ function resolveCodexHome() {
654
+ return path.resolve(process.env.CODEX_HOME || path.join(os.homedir(), ".codex"));
655
+ }
656
+
657
+ function sourceContentSha256(sourcePath) {
658
+ return crypto.createHash("sha256").update(fs.readFileSync(sourcePath)).digest("hex");
659
+ }
660
+
661
+ function importedThreadIdForSource(sourcePath) {
662
+ const ledgerPath = path.join(resolveCodexHome(), "external_agent_session_imports.json");
663
+ if (!fs.existsSync(ledgerPath)) {
664
+ return null;
665
+ }
666
+ const ledger = readJsonFile(ledgerPath);
667
+ const canonicalSource = fs.realpathSync(sourcePath);
668
+ const contentSha256 = sourceContentSha256(canonicalSource);
669
+ const records = Array.isArray(ledger?.records) ? ledger.records : [];
670
+ const match = records
671
+ .filter(
672
+ (record) =>
673
+ record?.source_path === canonicalSource &&
674
+ record?.content_sha256 === contentSha256 &&
675
+ typeof record?.imported_thread_id === "string"
676
+ )
677
+ .at(-1);
678
+ return match?.imported_thread_id ?? null;
679
+ }
680
+
681
+ function externalAgentSessionMigration(sourcePath, cwd) {
682
+ return {
683
+ migrationItems: [
684
+ {
685
+ itemType: "SESSIONS",
686
+ description: `Transfer Claude session ${path.basename(sourcePath)}`,
687
+ cwd: null,
688
+ details: {
689
+ plugins: [],
690
+ sessions: [{ path: sourcePath, cwd, title: null }],
691
+ mcpServers: [],
692
+ hooks: [],
693
+ subagents: [],
694
+ commands: []
695
+ }
696
+ }
697
+ ]
698
+ };
699
+ }
700
+
701
+ async function requestExternalAgentSessionImport(client, params) {
702
+ const previousHandler = client.notificationHandler;
703
+ let timeout = null;
704
+ let resolveCompleted;
705
+ let rejectCompleted;
706
+ const completed = new Promise((resolve, reject) => {
707
+ resolveCompleted = resolve;
708
+ rejectCompleted = reject;
709
+ });
710
+ void completed.catch(() => {});
711
+
712
+ client.setNotificationHandler((message) => {
713
+ if (message.method === EXTERNAL_AGENT_IMPORT_COMPLETED) {
714
+ resolveCompleted();
715
+ return;
716
+ }
717
+ previousHandler?.(message);
718
+ });
719
+ timeout = setTimeout(() => {
720
+ rejectCompleted(new Error("Timed out waiting for Codex to finish importing the Claude session."));
721
+ }, EXTERNAL_AGENT_IMPORT_TIMEOUT_MS);
722
+
723
+ try {
724
+ await client.request("externalAgentConfig/import", params);
725
+ await completed;
726
+ } finally {
727
+ clearTimeout(timeout);
728
+ client.setNotificationHandler(previousHandler ?? null);
729
+ }
730
+ }
731
+
732
+ async function startThread(client, cwd, options = {}) {
733
+ const response = await client.request("thread/start", buildThreadParams(cwd, options));
734
+ const threadId = response.thread.id;
735
+ if (options.threadName) {
736
+ try {
737
+ await client.request("thread/name/set", { threadId, name: options.threadName });
738
+ } catch (err) {
739
+ // Only suppress "unknown variant/method" errors from older CLI versions
740
+ // that don't support thread/name/set. Rethrow auth, network, or server errors.
741
+ const msg = String(err?.message ?? err ?? "");
742
+ if (!msg.includes("unknown variant") && !msg.includes("unknown method")) {
743
+ throw err;
744
+ }
745
+ }
746
+ }
747
+ return response;
748
+ }
749
+
750
+ async function resumeThread(client, threadId, cwd, options = {}) {
751
+ return client.request("thread/resume", buildResumeParams(threadId, cwd, options));
752
+ }
753
+
754
+ function buildResultStatus(turnState) {
755
+ return turnState.finalTurn?.status === "completed" ? 0 : 1;
756
+ }
757
+
758
+ const BUILTIN_PROVIDER_LABELS = new Map([
759
+ ["openai", "OpenAI"],
760
+ ["ollama", "Ollama"],
761
+ ["lmstudio", "LM Studio"]
762
+ ]);
763
+
764
+ function normalizeProviderId(value) {
765
+ const providerId = typeof value === "string" ? value.trim() : "";
766
+ return providerId || null;
767
+ }
768
+
769
+ function formatProviderLabel(providerId, providerConfig = null) {
770
+ const configuredName = typeof providerConfig?.name === "string" ? providerConfig.name.trim() : "";
771
+ if (configuredName) {
772
+ return configuredName;
773
+ }
774
+ if (!providerId) {
775
+ return "The active provider";
776
+ }
777
+ return BUILTIN_PROVIDER_LABELS.get(providerId) ?? providerId;
778
+ }
779
+
780
+ function buildAuthStatus(fields = {}) {
781
+ return {
782
+ available: true,
783
+ loggedIn: false,
784
+ detail: "not authenticated",
785
+ source: "unknown",
786
+ authMethod: null,
787
+ verified: null,
788
+ requiresOpenaiAuth: null,
789
+ provider: null,
790
+ ...fields
791
+ };
792
+ }
793
+
794
+ function resolveProviderConfig(configResponse) {
795
+ const config = configResponse?.config;
796
+ if (!config || typeof config !== "object") {
797
+ return {
798
+ providerId: null,
799
+ providerConfig: null
800
+ };
801
+ }
802
+
803
+ const providerId = normalizeProviderId(config.model_provider);
804
+ const providers =
805
+ config.model_providers && typeof config.model_providers === "object" && !Array.isArray(config.model_providers)
806
+ ? config.model_providers
807
+ : null;
808
+ const providerConfig =
809
+ providerId && providers?.[providerId] && typeof providers[providerId] === "object" ? providers[providerId] : null;
810
+
811
+ return {
812
+ providerId,
813
+ providerConfig
814
+ };
815
+ }
816
+
817
+ function buildAppServerAuthStatus(accountResponse, configResponse) {
818
+ const account = accountResponse?.account ?? null;
819
+ const requiresOpenaiAuth =
820
+ typeof accountResponse?.requiresOpenaiAuth === "boolean" ? accountResponse.requiresOpenaiAuth : null;
821
+ const { providerId, providerConfig } = resolveProviderConfig(configResponse);
822
+ const providerLabel = formatProviderLabel(providerId, providerConfig);
823
+
824
+ if (account?.type === "chatgpt") {
825
+ const email = typeof account.email === "string" && account.email.trim() ? account.email.trim() : null;
826
+ return buildAuthStatus({
827
+ loggedIn: true,
828
+ detail: email ? `ChatGPT login active for ${email}` : "ChatGPT login active",
829
+ source: "app-server",
830
+ authMethod: "chatgpt",
831
+ verified: true,
832
+ requiresOpenaiAuth,
833
+ provider: providerId
834
+ });
835
+ }
836
+
837
+ if (account?.type === "apiKey") {
838
+ return buildAuthStatus({
839
+ loggedIn: true,
840
+ detail: "API key configured (unverified)",
841
+ source: "app-server",
842
+ authMethod: "apiKey",
843
+ verified: false,
844
+ requiresOpenaiAuth,
845
+ provider: providerId
846
+ });
847
+ }
848
+
849
+ if (requiresOpenaiAuth === false) {
850
+ return buildAuthStatus({
851
+ loggedIn: true,
852
+ detail: `${providerLabel} is configured and does not require OpenAI authentication`,
853
+ source: "app-server",
854
+ requiresOpenaiAuth,
855
+ provider: providerId
856
+ });
857
+ }
858
+
859
+ return buildAuthStatus({
860
+ loggedIn: false,
861
+ detail: `${providerLabel} requires OpenAI authentication`,
862
+ source: "app-server",
863
+ requiresOpenaiAuth,
864
+ provider: providerId
865
+ });
866
+ }
867
+
868
+ async function getCodexAuthStatusFromClient(client, cwd) {
869
+ try {
870
+ const accountResponse = await client.request("account/read", { refreshToken: false });
871
+ const configResponse = await client.request("config/read", {
872
+ includeLayers: false,
873
+ cwd
874
+ });
875
+
876
+ return buildAppServerAuthStatus(accountResponse, configResponse);
877
+ } catch (error) {
878
+ return buildAuthStatus({
879
+ loggedIn: false,
880
+ detail: error instanceof Error ? error.message : String(error),
881
+ source: "app-server"
882
+ });
883
+ }
884
+ }
885
+
886
+ export function getCodexAvailability(cwd) {
887
+ const versionStatus = binaryAvailable("codex", ["--version"], { cwd });
888
+ if (!versionStatus.available) {
889
+ return versionStatus;
890
+ }
891
+
892
+ const appServerStatus = binaryAvailable("codex", ["app-server", "--help"], { cwd });
893
+ if (!appServerStatus.available) {
894
+ return {
895
+ available: false,
896
+ detail: `${versionStatus.detail}; advanced runtime unavailable: ${appServerStatus.detail}`
897
+ };
898
+ }
899
+
900
+ return {
901
+ available: true,
902
+ detail: `${versionStatus.detail}; advanced runtime available`
903
+ };
904
+ }
905
+
906
+ export function getSessionRuntimeStatus(env = process.env, cwd = process.cwd()) {
907
+ const endpoint = env?.[BROKER_ENDPOINT_ENV] ?? loadBrokerSession(cwd)?.endpoint ?? null;
908
+ if (endpoint) {
909
+ return {
910
+ mode: "shared",
911
+ label: "shared session",
912
+ detail: "This Claude session is configured to reuse one shared Codex runtime.",
913
+ endpoint
914
+ };
915
+ }
916
+
917
+ return {
918
+ mode: "direct",
919
+ label: "direct startup",
920
+ detail: "No shared Codex runtime is active yet. The first review or task command will start one on demand.",
921
+ endpoint: null
922
+ };
923
+ }
924
+
925
+ export async function getCodexAuthStatus(cwd, options = {}) {
926
+ const availability = getCodexAvailability(cwd);
927
+ if (!availability.available) {
928
+ return {
929
+ available: false,
930
+ loggedIn: false,
931
+ detail: availability.detail,
932
+ source: "availability",
933
+ authMethod: null,
934
+ verified: null,
935
+ requiresOpenaiAuth: null,
936
+ provider: null
937
+ };
938
+ }
939
+
940
+ let client = null;
941
+ try {
942
+ client = await CodexAppServerClient.connect(cwd, {
943
+ env: options.env,
944
+ reuseExistingBroker: true
945
+ });
946
+ return await getCodexAuthStatusFromClient(client, cwd);
947
+ } catch (error) {
948
+ return buildAuthStatus({
949
+ loggedIn: false,
950
+ detail: error instanceof Error ? error.message : String(error),
951
+ source: "app-server"
952
+ });
953
+ } finally {
954
+ if (client) {
955
+ await client.close().catch(() => {});
956
+ }
957
+ }
958
+ }
959
+
960
+ export async function interruptAppServerTurn(cwd, { threadId, turnId }) {
961
+ if (!threadId || !turnId) {
962
+ return {
963
+ attempted: false,
964
+ interrupted: false,
965
+ transport: null,
966
+ detail: "missing threadId or turnId"
967
+ };
968
+ }
969
+
970
+ const availability = getCodexAvailability(cwd);
971
+ if (!availability.available) {
972
+ return {
973
+ attempted: false,
974
+ interrupted: false,
975
+ transport: null,
976
+ detail: availability.detail
977
+ };
978
+ }
979
+
980
+ let client = null;
981
+ try {
982
+ client = await CodexAppServerClient.connect(cwd, { reuseExistingBroker: true });
983
+ await client.request("turn/interrupt", { threadId, turnId });
984
+ return {
985
+ attempted: true,
986
+ interrupted: true,
987
+ transport: client.transport,
988
+ detail: `Interrupted ${turnId} on ${threadId}.`
989
+ };
990
+ } catch (error) {
991
+ return {
992
+ attempted: true,
993
+ interrupted: false,
994
+ transport: client?.transport ?? null,
995
+ detail: error instanceof Error ? error.message : String(error)
996
+ };
997
+ } finally {
998
+ await client?.close().catch(() => {});
999
+ }
1000
+ }
1001
+
1002
+ export async function runAppServerReview(cwd, options = {}) {
1003
+ const availability = getCodexAvailability(cwd);
1004
+ if (!availability.available) {
1005
+ throw new Error("Codex CLI is not installed or is missing required runtime support. Install it with `npm install -g @openai/codex`, then rerun `/codex:setup`.");
1006
+ }
1007
+
1008
+ return withAppServer(cwd, async (client) => {
1009
+ emitProgress(options.onProgress, "Starting Codex review thread.", "starting");
1010
+ const thread = await startThread(client, cwd, {
1011
+ model: options.model,
1012
+ sandbox: "read-only",
1013
+ ephemeral: true,
1014
+ threadName: options.threadName
1015
+ });
1016
+ const sourceThreadId = thread.thread.id;
1017
+ emitProgress(options.onProgress, `Thread ready (${sourceThreadId}).`, "starting", {
1018
+ threadId: sourceThreadId
1019
+ });
1020
+ const delivery = options.delivery ?? "inline";
1021
+
1022
+ const turnState = await captureTurn(
1023
+ client,
1024
+ sourceThreadId,
1025
+ () =>
1026
+ client.request("review/start", {
1027
+ threadId: sourceThreadId,
1028
+ delivery,
1029
+ target: options.target
1030
+ }),
1031
+ {
1032
+ onProgress: options.onProgress,
1033
+ onResponse(response, state) {
1034
+ if (response.reviewThreadId) {
1035
+ state.threadIds.add(response.reviewThreadId);
1036
+ if (delivery === "detached") {
1037
+ state.threadId = response.reviewThreadId;
1038
+ }
1039
+ }
1040
+ }
1041
+ }
1042
+ );
1043
+
1044
+ return {
1045
+ status: buildResultStatus(turnState),
1046
+ threadId: turnState.threadId,
1047
+ sourceThreadId,
1048
+ turnId: turnState.turnId,
1049
+ reviewText: turnState.reviewText,
1050
+ reasoningSummary: turnState.reasoningSummary,
1051
+ turn: turnState.finalTurn,
1052
+ error: turnState.error,
1053
+ stderr: cleanCodexStderr(client.stderr)
1054
+ };
1055
+ });
1056
+ }
1057
+
1058
+ export async function importExternalAgentSession(cwd, options = {}) {
1059
+ const availability = getCodexAvailability(cwd);
1060
+ if (!availability.available) {
1061
+ throw new Error("Codex CLI is not installed or is missing required runtime support. Install it with `npm install -g @openai/codex`, then rerun `/codex:setup`.");
1062
+ }
1063
+ if (!options.sourcePath) {
1064
+ throw new Error("A Claude session source path is required.");
1065
+ }
1066
+
1067
+ return withDirectAppServer(cwd, async (client) => {
1068
+ emitProgress(options.onProgress, "Importing Claude session into Codex.", "transferring");
1069
+ try {
1070
+ await requestExternalAgentSessionImport(client, externalAgentSessionMigration(options.sourcePath, cwd));
1071
+ } catch (error) {
1072
+ if (error?.rpcCode === -32601) {
1073
+ throw new Error(
1074
+ "This Codex version does not support Claude session transfer. Update Codex with `npm install -g @openai/codex@latest`, then retry.",
1075
+ { cause: error }
1076
+ );
1077
+ }
1078
+ throw error;
1079
+ }
1080
+ const threadId = importedThreadIdForSource(options.sourcePath);
1081
+ if (!threadId) {
1082
+ const stderr = cleanCodexStderr(client.stderr);
1083
+ throw new Error(
1084
+ `Codex reported that the Claude import completed, but did not record an imported thread.${stderr ? `\n${stderr}` : " Check the Codex app-server logs for the underlying import error."}`
1085
+ );
1086
+ }
1087
+ emitProgress(options.onProgress, `Claude session imported (${threadId}).`, "completed", { threadId });
1088
+ return {
1089
+ threadId,
1090
+ stderr: cleanCodexStderr(client.stderr)
1091
+ };
1092
+ });
1093
+ }
1094
+
1095
+ export async function runAppServerTurn(cwd, options = {}) {
1096
+ const availability = getCodexAvailability(cwd);
1097
+ if (!availability.available) {
1098
+ throw new Error("Codex CLI is not installed or is missing required runtime support. Install it with `npm install -g @openai/codex`, then rerun `/codex:setup`.");
1099
+ }
1100
+
1101
+ return withAppServer(cwd, async (client) => {
1102
+ let threadId;
1103
+
1104
+ if (options.resumeThreadId) {
1105
+ emitProgress(options.onProgress, `Resuming thread ${options.resumeThreadId}.`, "starting");
1106
+ const response = await resumeThread(client, options.resumeThreadId, cwd, {
1107
+ model: options.model,
1108
+ sandbox: options.sandbox,
1109
+ ephemeral: false
1110
+ });
1111
+ threadId = response.thread.id;
1112
+ } else {
1113
+ emitProgress(options.onProgress, "Starting Codex task thread.", "starting");
1114
+ const response = await startThread(client, cwd, {
1115
+ model: options.model,
1116
+ sandbox: options.sandbox,
1117
+ ephemeral: options.persistThread ? false : true,
1118
+ threadName: options.persistThread ? options.threadName : options.threadName ?? null
1119
+ });
1120
+ threadId = response.thread.id;
1121
+ }
1122
+
1123
+ emitProgress(options.onProgress, `Thread ready (${threadId}).`, "starting", {
1124
+ threadId
1125
+ });
1126
+
1127
+ const prompt = options.prompt?.trim() || options.defaultPrompt || "";
1128
+ if (!prompt) {
1129
+ throw new Error("A prompt is required for this Codex run.");
1130
+ }
1131
+
1132
+ const turnState = await captureTurn(
1133
+ client,
1134
+ threadId,
1135
+ () =>
1136
+ client.request("turn/start", {
1137
+ threadId,
1138
+ input: buildTurnInput(prompt),
1139
+ model: options.model ?? null,
1140
+ effort: options.effort ?? null,
1141
+ outputSchema: options.outputSchema ?? null
1142
+ }),
1143
+ { onProgress: options.onProgress }
1144
+ );
1145
+
1146
+ return {
1147
+ status: buildResultStatus(turnState),
1148
+ threadId,
1149
+ turnId: turnState.turnId,
1150
+ finalMessage: turnState.lastAgentMessage,
1151
+ reasoningSummary: turnState.reasoningSummary,
1152
+ turn: turnState.finalTurn,
1153
+ error: turnState.error,
1154
+ stderr: cleanCodexStderr(client.stderr),
1155
+ fileChanges: turnState.fileChanges,
1156
+ touchedFiles: collectTouchedFiles(turnState.fileChanges),
1157
+ commandExecutions: turnState.commandExecutions
1158
+ };
1159
+ });
1160
+ }
1161
+
1162
+ export async function findLatestTaskThread(cwd) {
1163
+ const availability = getCodexAvailability(cwd);
1164
+ if (!availability.available) {
1165
+ throw new Error("Codex CLI is not installed or is missing required runtime support. Install it with `npm install -g @openai/codex`, then rerun `/codex:setup`.");
1166
+ }
1167
+
1168
+ return withAppServer(cwd, async (client) => {
1169
+ const response = await client.request("thread/list", {
1170
+ cwd,
1171
+ limit: 20,
1172
+ sortKey: "updated_at",
1173
+ sourceKinds: ["appServer"],
1174
+ searchTerm: TASK_THREAD_PREFIX
1175
+ });
1176
+
1177
+ return (
1178
+ response.data.find((thread) => typeof thread.name === "string" && thread.name.startsWith(TASK_THREAD_PREFIX)) ??
1179
+ null
1180
+ );
1181
+ });
1182
+ }
1183
+
1184
+ export function buildPersistentTaskThreadName(prompt) {
1185
+ return buildTaskThreadName(prompt);
1186
+ }
1187
+
1188
+ export function parseStructuredOutput(rawOutput, fallback = {}) {
1189
+ if (!rawOutput) {
1190
+ return {
1191
+ parsed: null,
1192
+ parseError: fallback.failureMessage ?? "Codex did not return a final structured message.",
1193
+ rawOutput: rawOutput ?? "",
1194
+ ...fallback
1195
+ };
1196
+ }
1197
+
1198
+ try {
1199
+ return {
1200
+ parsed: JSON.parse(rawOutput),
1201
+ parseError: null,
1202
+ rawOutput,
1203
+ ...fallback
1204
+ };
1205
+ } catch (error) {
1206
+ return {
1207
+ parsed: null,
1208
+ parseError: error.message,
1209
+ rawOutput,
1210
+ ...fallback
1211
+ };
1212
+ }
1213
+ }
1214
+
1215
+ export function readOutputSchema(schemaPath) {
1216
+ return readJsonFile(schemaPath);
1217
+ }
1218
+
1219
+ export { DEFAULT_CONTINUE_PROMPT, TASK_THREAD_PREFIX };