@jjlabsio/claude-crew 0.1.30 → 0.1.32

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