openclaw-codex-app-server 0.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/client.ts ADDED
@@ -0,0 +1,2914 @@
1
+ import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
2
+ import * as path from "node:path";
3
+ import readline from "node:readline";
4
+ import WebSocket from "ws";
5
+ import type { PluginLogger } from "openclaw/plugin-sdk";
6
+ import { createPendingInputState, parseCodexUserInput } from "./pending-input.js";
7
+ import type {
8
+ AccountSummary,
9
+ CollaborationMode,
10
+ CompactProgress,
11
+ CompactResult,
12
+ ContextUsageSnapshot,
13
+ ExperimentalFeatureSummary,
14
+ McpServerSummary,
15
+ ModelSummary,
16
+ PendingInputAction,
17
+ PendingInputState,
18
+ PluginSettings,
19
+ RateLimitSummary,
20
+ ReviewResult,
21
+ ReviewTarget,
22
+ SkillSummary,
23
+ ThreadReplay,
24
+ ThreadState,
25
+ ThreadSummary,
26
+ TurnResult,
27
+ } from "./types.js";
28
+
29
+ type JsonRpcId = string | number;
30
+ type JsonRpcEnvelope = {
31
+ jsonrpc?: string;
32
+ id?: JsonRpcId | null;
33
+ method?: string;
34
+ params?: unknown;
35
+ result?: unknown;
36
+ error?: {
37
+ code?: number;
38
+ message?: string;
39
+ data?: unknown;
40
+ };
41
+ };
42
+
43
+ type PendingRequest = {
44
+ resolve: (value: unknown) => void;
45
+ reject: (error: Error) => void;
46
+ timer: NodeJS.Timeout;
47
+ };
48
+
49
+ type JsonRpcNotificationHandler = (method: string, params: unknown) => Promise<void> | void;
50
+ type JsonRpcRequestHandler = (method: string, params: unknown) => Promise<unknown>;
51
+
52
+ type JsonRpcClient = {
53
+ connect: () => Promise<void>;
54
+ close: () => Promise<void>;
55
+ notify: (method: string, params?: unknown) => Promise<void>;
56
+ request: (method: string, params?: unknown, timeoutMs?: number) => Promise<unknown>;
57
+ setNotificationHandler: (handler: JsonRpcNotificationHandler) => void;
58
+ setRequestHandler: (handler: JsonRpcRequestHandler) => void;
59
+ };
60
+
61
+ export type ActiveCodexRun = {
62
+ result: Promise<TurnResult | ReviewResult>;
63
+ queueMessage: (text: string) => Promise<boolean>;
64
+ submitPendingInput: (actionIndex: number) => Promise<boolean>;
65
+ submitPendingInputPayload: (payload: unknown) => Promise<boolean>;
66
+ interrupt: () => Promise<void>;
67
+ isAwaitingInput: () => boolean;
68
+ getThreadId: () => string | undefined;
69
+ };
70
+
71
+ const DEFAULT_PROTOCOL_VERSION = "1.0";
72
+ const TRAILING_NOTIFICATION_SETTLE_MS = 250;
73
+ const TURN_STEER_METHODS = ["turn/steer"] as const;
74
+ const TURN_INTERRUPT_METHODS = ["turn/interrupt"] as const;
75
+
76
+ function isTransportClosedError(error: unknown): boolean {
77
+ const text = error instanceof Error ? error.message : String(error);
78
+ const normalized = text.trim().toLowerCase();
79
+ return (
80
+ normalized.includes("stdio not connected") ||
81
+ normalized.includes("websocket not connected") ||
82
+ normalized.includes("stdio closed") ||
83
+ normalized.includes("websocket closed") ||
84
+ normalized.includes("socket closed") ||
85
+ normalized.includes("broken pipe")
86
+ );
87
+ }
88
+
89
+ function asRecord(value: unknown): Record<string, unknown> | null {
90
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
91
+ return null;
92
+ }
93
+ return value as Record<string, unknown>;
94
+ }
95
+
96
+ function pickString(
97
+ record: Record<string, unknown>,
98
+ keys: string[],
99
+ options?: { trim?: boolean },
100
+ ): string | undefined {
101
+ for (const key of keys) {
102
+ const value = record[key];
103
+ if (typeof value !== "string") {
104
+ continue;
105
+ }
106
+ const text = options?.trim === false ? value : value.trim();
107
+ if (text) {
108
+ return text;
109
+ }
110
+ }
111
+ return undefined;
112
+ }
113
+
114
+ function pickNumber(record: Record<string, unknown>, keys: string[]): number | undefined {
115
+ for (const key of keys) {
116
+ const value = record[key];
117
+ if (typeof value === "number" && Number.isFinite(value)) {
118
+ return value;
119
+ }
120
+ if (typeof value === "string") {
121
+ const parsed = Date.parse(value);
122
+ if (!Number.isNaN(parsed)) {
123
+ return parsed;
124
+ }
125
+ }
126
+ }
127
+ return undefined;
128
+ }
129
+
130
+ function pickFiniteNumber(record: Record<string, unknown>, keys: string[]): number | undefined {
131
+ for (const key of keys) {
132
+ const value = record[key];
133
+ if (typeof value === "number" && Number.isFinite(value)) {
134
+ return value;
135
+ }
136
+ if (typeof value === "string") {
137
+ const parsed = Number(value.trim());
138
+ if (Number.isFinite(parsed)) {
139
+ return parsed;
140
+ }
141
+ }
142
+ }
143
+ return undefined;
144
+ }
145
+
146
+ function pickBoolean(record: Record<string, unknown>, keys: string[]): boolean | undefined {
147
+ for (const key of keys) {
148
+ const value = record[key];
149
+ if (typeof value === "boolean") {
150
+ return value;
151
+ }
152
+ if (typeof value === "string") {
153
+ const normalized = value.trim().toLowerCase();
154
+ if (normalized === "true") {
155
+ return true;
156
+ }
157
+ if (normalized === "false") {
158
+ return false;
159
+ }
160
+ }
161
+ }
162
+ return undefined;
163
+ }
164
+
165
+ function collectText(value: unknown): string[] {
166
+ if (typeof value === "string") {
167
+ const trimmed = value.trim();
168
+ return trimmed ? [trimmed] : [];
169
+ }
170
+ if (Array.isArray(value)) {
171
+ return value.flatMap((entry) => collectText(entry));
172
+ }
173
+ const record = asRecord(value);
174
+ if (!record) {
175
+ return [];
176
+ }
177
+ const directKeys = [
178
+ "text",
179
+ "delta",
180
+ "message",
181
+ "prompt",
182
+ "question",
183
+ "summary",
184
+ "title",
185
+ "content",
186
+ "description",
187
+ "reason",
188
+ ];
189
+ const out = directKeys.flatMap((key) => collectText(record[key]));
190
+ for (const nestedKey of ["item", "turn", "thread", "response", "result", "data", "questions"]) {
191
+ out.push(...collectText(record[nestedKey]));
192
+ }
193
+ return out;
194
+ }
195
+
196
+ function findFirstNestedString(
197
+ value: unknown,
198
+ keys: readonly string[],
199
+ nestedKeys: readonly string[] = keys,
200
+ depth = 0,
201
+ ): string | undefined {
202
+ if (depth > 6) {
203
+ return undefined;
204
+ }
205
+ if (Array.isArray(value)) {
206
+ for (const entry of value) {
207
+ const match = findFirstNestedString(entry, keys, nestedKeys, depth + 1);
208
+ if (match) {
209
+ return match;
210
+ }
211
+ }
212
+ return undefined;
213
+ }
214
+ const record = asRecord(value);
215
+ if (!record) {
216
+ return undefined;
217
+ }
218
+ const direct = pickString(record, [...keys]);
219
+ if (direct) {
220
+ return direct;
221
+ }
222
+ for (const key of keys) {
223
+ const nestedRecord = asRecord(record[key]);
224
+ if (!nestedRecord) {
225
+ continue;
226
+ }
227
+ const nested = pickString(nestedRecord, [...nestedKeys]);
228
+ if (nested) {
229
+ return nested;
230
+ }
231
+ }
232
+ for (const nested of Object.values(record)) {
233
+ const match = findFirstNestedString(nested, keys, nestedKeys, depth + 1);
234
+ if (match) {
235
+ return match;
236
+ }
237
+ }
238
+ return undefined;
239
+ }
240
+
241
+ function findFirstArrayByKeys(
242
+ value: unknown,
243
+ keys: readonly string[],
244
+ depth = 0,
245
+ ): unknown[] | undefined {
246
+ if (depth > 6) {
247
+ return undefined;
248
+ }
249
+ if (Array.isArray(value)) {
250
+ for (const entry of value) {
251
+ const match = findFirstArrayByKeys(entry, keys, depth + 1);
252
+ if (match && match.length > 0) {
253
+ return match;
254
+ }
255
+ }
256
+ return undefined;
257
+ }
258
+ const record = asRecord(value);
259
+ if (!record) {
260
+ return undefined;
261
+ }
262
+ for (const key of keys) {
263
+ const nested = record[key];
264
+ if (Array.isArray(nested) && nested.length > 0) {
265
+ return nested;
266
+ }
267
+ }
268
+ for (const nested of Object.values(record)) {
269
+ const match = findFirstArrayByKeys(nested, keys, depth + 1);
270
+ if (match && match.length > 0) {
271
+ return match;
272
+ }
273
+ }
274
+ return undefined;
275
+ }
276
+
277
+ function findFirstNestedValue(value: unknown, keys: readonly string[], depth = 0): unknown {
278
+ if (depth > 6) {
279
+ return undefined;
280
+ }
281
+ if (Array.isArray(value)) {
282
+ for (const entry of value) {
283
+ const match = findFirstNestedValue(entry, keys, depth + 1);
284
+ if (match !== undefined) {
285
+ return match;
286
+ }
287
+ }
288
+ return undefined;
289
+ }
290
+ const record = asRecord(value);
291
+ if (!record) {
292
+ return undefined;
293
+ }
294
+ for (const key of keys) {
295
+ if (record[key] !== undefined) {
296
+ return record[key];
297
+ }
298
+ }
299
+ for (const nested of Object.values(record)) {
300
+ const match = findFirstNestedValue(nested, keys, depth + 1);
301
+ if (match !== undefined) {
302
+ return match;
303
+ }
304
+ }
305
+ return undefined;
306
+ }
307
+
308
+ function collectStreamingText(value: unknown): string {
309
+ if (typeof value === "string") {
310
+ return value;
311
+ }
312
+ if (Array.isArray(value)) {
313
+ return value.map((entry) => collectStreamingText(entry)).join("");
314
+ }
315
+ const record = asRecord(value);
316
+ if (!record) {
317
+ return "";
318
+ }
319
+ for (const key of ["delta", "text", "content", "message", "input", "output", "parts"]) {
320
+ const direct = collectStreamingText(record[key]);
321
+ if (direct) {
322
+ return direct;
323
+ }
324
+ }
325
+ for (const nestedKey of ["item", "turn", "thread", "response", "result", "data"]) {
326
+ const nested = collectStreamingText(record[nestedKey]);
327
+ if (nested) {
328
+ return nested;
329
+ }
330
+ }
331
+ return "";
332
+ }
333
+
334
+ function dedupeJoinedText(chunks: string[]): string {
335
+ const seen = new Set<string>();
336
+ const out: string[] = [];
337
+ for (const chunk of chunks.map((value) => value.trim()).filter(Boolean)) {
338
+ if (seen.has(chunk)) {
339
+ continue;
340
+ }
341
+ seen.add(chunk);
342
+ out.push(chunk);
343
+ }
344
+ return out.join("\n\n").trim();
345
+ }
346
+
347
+ function extractIds(value: unknown): {
348
+ threadId?: string;
349
+ runId?: string;
350
+ requestId?: string;
351
+ itemId?: string;
352
+ } {
353
+ const record = asRecord(value);
354
+ if (!record) {
355
+ return {};
356
+ }
357
+ const threadRecord = asRecord(record.thread) ?? asRecord(record.session);
358
+ const turnRecord = asRecord(record.turn) ?? asRecord(record.run);
359
+ return {
360
+ threadId:
361
+ pickString(record, ["threadId", "thread_id", "conversationId", "conversation_id"]) ??
362
+ pickString(threadRecord ?? {}, ["id", "threadId", "thread_id", "conversationId"]),
363
+ runId:
364
+ pickString(record, ["turnId", "turn_id", "runId", "run_id"]) ??
365
+ pickString(turnRecord ?? {}, ["id", "turnId", "turn_id", "runId", "run_id"]),
366
+ requestId:
367
+ pickString(record, ["requestId", "request_id", "serverRequestId"]) ??
368
+ pickString(asRecord(record.serverRequest) ?? {}, ["id", "requestId", "request_id"]),
369
+ itemId:
370
+ pickString(record, ["itemId", "item_id"]) ??
371
+ pickString(asRecord(record.item) ?? {}, ["id", "itemId", "item_id"]),
372
+ };
373
+ }
374
+
375
+ function extractOptionValues(value: unknown): string[] {
376
+ const rawOptions = findFirstArrayByKeys(value, [
377
+ "options",
378
+ "choices",
379
+ "availableDecisions",
380
+ "decisions",
381
+ ]);
382
+ if (!rawOptions) {
383
+ return [];
384
+ }
385
+ return rawOptions
386
+ .map((entry) => {
387
+ if (typeof entry === "string") {
388
+ return entry.trim();
389
+ }
390
+ return (
391
+ pickString(asRecord(entry) ?? {}, ["label", "title", "text", "value", "name", "id"]) ?? ""
392
+ );
393
+ })
394
+ .filter(Boolean);
395
+ }
396
+
397
+ function isInteractiveServerRequest(method: string): boolean {
398
+ const normalized = method.trim().toLowerCase();
399
+ return normalized.includes("requestuserinput") || normalized.includes("requestapproval");
400
+ }
401
+
402
+ function isMethodUnavailableError(error: unknown, method?: string): boolean {
403
+ const text = error instanceof Error ? error.message : String(error);
404
+ const normalized = text.toLowerCase();
405
+ if (normalized.includes("method not found") || normalized.includes("unknown method")) {
406
+ return true;
407
+ }
408
+ if (!normalized.includes("unknown variant")) {
409
+ return false;
410
+ }
411
+ if (!method) {
412
+ return true;
413
+ }
414
+ return normalized.includes(`unknown variant \`${method.toLowerCase()}\``);
415
+ }
416
+
417
+ const RPC_METHODS_REQUIRING_THREAD_ID = new Set([
418
+ "thread/resume",
419
+ "thread/unsubscribe",
420
+ "thread/name/set",
421
+ "thread/compact/start",
422
+ "thread/read",
423
+ "turn/start",
424
+ "turn/steer",
425
+ "turn/interrupt",
426
+ "review/start",
427
+ ]);
428
+
429
+ function methodRequiresThreadId(method: string): boolean {
430
+ return RPC_METHODS_REQUIRING_THREAD_ID.has(method.trim().toLowerCase());
431
+ }
432
+
433
+ function payloadHasThreadId(payload: unknown): boolean {
434
+ const record = asRecord(payload);
435
+ if (!record) {
436
+ return false;
437
+ }
438
+ return Boolean(
439
+ pickString(record, ["threadId", "thread_id"]) ??
440
+ findFirstNestedString(record, ["threadId", "thread_id"]),
441
+ );
442
+ }
443
+
444
+ class WsJsonRpcClient implements JsonRpcClient {
445
+ private socket: any = null;
446
+ private readonly pending = new Map<string, PendingRequest>();
447
+ private counter = 0;
448
+ private onNotification: JsonRpcNotificationHandler = () => undefined;
449
+ private onRequest: JsonRpcRequestHandler = async () => ({});
450
+
451
+ constructor(
452
+ private readonly url: string,
453
+ private readonly headers: Record<string, string> | undefined,
454
+ private readonly requestTimeoutMs: number,
455
+ ) {}
456
+
457
+ setNotificationHandler(handler: JsonRpcNotificationHandler): void {
458
+ this.onNotification = handler;
459
+ }
460
+
461
+ setRequestHandler(handler: JsonRpcRequestHandler): void {
462
+ this.onRequest = handler;
463
+ }
464
+
465
+ async connect(): Promise<void> {
466
+ if (this.socket?.readyState === WebSocket.OPEN) {
467
+ return;
468
+ }
469
+ this.socket = await new Promise<any>((resolve, reject) => {
470
+ const socket = new WebSocket(this.url, { headers: this.headers });
471
+ socket.once("open", () => resolve(socket));
472
+ socket.once("error", (error: unknown) => reject(error));
473
+ });
474
+ this.socket.on("message", (data: any) => {
475
+ const text =
476
+ typeof data === "string"
477
+ ? data
478
+ : Buffer.isBuffer(data)
479
+ ? data.toString("utf8")
480
+ : Buffer.from(String(data)).toString("utf8");
481
+ void this.handleMessage(text);
482
+ });
483
+ this.socket.on("close", () => {
484
+ this.flushPending(new Error("codex app server websocket closed"));
485
+ this.socket = null;
486
+ });
487
+ }
488
+
489
+ async close(): Promise<void> {
490
+ this.flushPending(new Error("codex app server websocket closed"));
491
+ const socket = this.socket;
492
+ this.socket = null;
493
+ if (!socket) {
494
+ return;
495
+ }
496
+ await new Promise<void>((resolve) => {
497
+ socket.once("close", () => resolve());
498
+ socket.close();
499
+ setTimeout(resolve, 250);
500
+ });
501
+ }
502
+
503
+ async notify(method: string, params?: unknown): Promise<void> {
504
+ this.send({ jsonrpc: "2.0", method, params: params ?? {} });
505
+ }
506
+
507
+ async request(method: string, params?: unknown, timeoutMs?: number): Promise<unknown> {
508
+ const id = `rpc-${++this.counter}`;
509
+ const result = new Promise<unknown>((resolve, reject) => {
510
+ const timer = setTimeout(() => {
511
+ this.pending.delete(id);
512
+ reject(new Error(`codex app server timeout: ${method}`));
513
+ }, Math.max(100, timeoutMs ?? this.requestTimeoutMs));
514
+ this.pending.set(id, { resolve, reject, timer });
515
+ });
516
+ this.send({ jsonrpc: "2.0", id, method, params: params ?? {} });
517
+ return await result;
518
+ }
519
+
520
+ private send(payload: JsonRpcEnvelope): void {
521
+ const socket = this.socket;
522
+ if (!socket || socket.readyState !== WebSocket.OPEN) {
523
+ throw new Error("codex app server websocket not connected");
524
+ }
525
+ socket.send(JSON.stringify(payload));
526
+ }
527
+
528
+ private async handleMessage(raw: string): Promise<void> {
529
+ const payload = parseJsonRpc(raw);
530
+ if (!payload) {
531
+ return;
532
+ }
533
+ await dispatchJsonRpcEnvelope(payload, {
534
+ pending: this.pending,
535
+ onNotification: this.onNotification,
536
+ onRequest: this.onRequest,
537
+ respond: (frame) => this.send(frame),
538
+ });
539
+ }
540
+
541
+ private flushPending(error: Error): void {
542
+ for (const [id, pending] of this.pending) {
543
+ clearTimeout(pending.timer);
544
+ pending.reject(error);
545
+ this.pending.delete(id);
546
+ }
547
+ }
548
+ }
549
+
550
+ class StdioJsonRpcClient implements JsonRpcClient {
551
+ private process: ChildProcessWithoutNullStreams | null = null;
552
+ private readonly pending = new Map<string, PendingRequest>();
553
+ private counter = 0;
554
+ private onNotification: JsonRpcNotificationHandler = () => undefined;
555
+ private onRequest: JsonRpcRequestHandler = async () => ({});
556
+
557
+ constructor(
558
+ private readonly command: string,
559
+ private readonly args: string[],
560
+ private readonly requestTimeoutMs: number,
561
+ ) {}
562
+
563
+ setNotificationHandler(handler: JsonRpcNotificationHandler): void {
564
+ this.onNotification = handler;
565
+ }
566
+
567
+ setRequestHandler(handler: JsonRpcRequestHandler): void {
568
+ this.onRequest = handler;
569
+ }
570
+
571
+ async connect(): Promise<void> {
572
+ if (this.process) {
573
+ return;
574
+ }
575
+ const child = spawn(this.command, ["app-server", ...this.args], {
576
+ stdio: ["pipe", "pipe", "pipe"],
577
+ env: process.env,
578
+ });
579
+ if (!child.stdin || !child.stdout || !child.stderr) {
580
+ throw new Error("codex app server stdio pipes unavailable");
581
+ }
582
+ this.process = child;
583
+ const lineReader = readline.createInterface({ input: child.stdout });
584
+ lineReader.on("line", (line) => {
585
+ void this.handleLine(line);
586
+ });
587
+ child.stderr.on("data", () => undefined);
588
+ child.on("close", () => {
589
+ this.flushPending(new Error("codex app server stdio closed"));
590
+ this.process = null;
591
+ });
592
+ }
593
+
594
+ async close(): Promise<void> {
595
+ this.flushPending(new Error("codex app server stdio closed"));
596
+ const child = this.process;
597
+ this.process = null;
598
+ if (!child) {
599
+ return;
600
+ }
601
+ child.kill();
602
+ }
603
+
604
+ async notify(method: string, params?: unknown): Promise<void> {
605
+ this.write({ jsonrpc: "2.0", method, params: params ?? {} });
606
+ }
607
+
608
+ async request(method: string, params?: unknown, timeoutMs?: number): Promise<unknown> {
609
+ const id = `rpc-${++this.counter}`;
610
+ const result = new Promise<unknown>((resolve, reject) => {
611
+ const timer = setTimeout(() => {
612
+ this.pending.delete(id);
613
+ reject(new Error(`codex app server timeout: ${method}`));
614
+ }, Math.max(100, timeoutMs ?? this.requestTimeoutMs));
615
+ this.pending.set(id, { resolve, reject, timer });
616
+ });
617
+ this.write({ jsonrpc: "2.0", id, method, params: params ?? {} });
618
+ return await result;
619
+ }
620
+
621
+ private write(payload: JsonRpcEnvelope): void {
622
+ const child = this.process;
623
+ if (!child?.stdin) {
624
+ throw new Error("codex app server stdio not connected");
625
+ }
626
+ child.stdin.write(`${JSON.stringify(payload)}\n`);
627
+ }
628
+
629
+ private async handleLine(line: string): Promise<void> {
630
+ const payload = parseJsonRpc(line);
631
+ if (!payload) {
632
+ return;
633
+ }
634
+ await dispatchJsonRpcEnvelope(payload, {
635
+ pending: this.pending,
636
+ onNotification: this.onNotification,
637
+ onRequest: this.onRequest,
638
+ respond: (frame) => this.write(frame),
639
+ });
640
+ }
641
+
642
+ private flushPending(error: Error): void {
643
+ for (const [id, pending] of this.pending) {
644
+ clearTimeout(pending.timer);
645
+ pending.reject(error);
646
+ this.pending.delete(id);
647
+ }
648
+ }
649
+ }
650
+
651
+ function parseJsonRpc(raw: string): JsonRpcEnvelope | null {
652
+ try {
653
+ const payload = JSON.parse(raw) as unknown;
654
+ if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
655
+ return null;
656
+ }
657
+ return payload as JsonRpcEnvelope;
658
+ } catch {
659
+ return null;
660
+ }
661
+ }
662
+
663
+ async function dispatchJsonRpcEnvelope(
664
+ payload: JsonRpcEnvelope,
665
+ params: {
666
+ pending: Map<string, PendingRequest>;
667
+ onNotification: JsonRpcNotificationHandler;
668
+ onRequest: JsonRpcRequestHandler;
669
+ respond: (payload: JsonRpcEnvelope) => void;
670
+ },
671
+ ): Promise<void> {
672
+ if (payload.id != null && (Object.hasOwn(payload, "result") || Object.hasOwn(payload, "error"))) {
673
+ const key = String(payload.id);
674
+ const pending = params.pending.get(key);
675
+ if (!pending) {
676
+ return;
677
+ }
678
+ clearTimeout(pending.timer);
679
+ params.pending.delete(key);
680
+ if (payload.error) {
681
+ pending.reject(
682
+ new Error(
683
+ `codex app server rpc error (${payload.error.code ?? "unknown"}): ${payload.error.message ?? "unknown error"}`,
684
+ ),
685
+ );
686
+ return;
687
+ }
688
+ pending.resolve(payload.result);
689
+ return;
690
+ }
691
+
692
+ const method = payload.method?.trim();
693
+ if (!method) {
694
+ return;
695
+ }
696
+ if (payload.id == null) {
697
+ await params.onNotification(method, payload.params);
698
+ return;
699
+ }
700
+ try {
701
+ const result = await params.onRequest(method, payload.params);
702
+ params.respond({
703
+ jsonrpc: "2.0",
704
+ id: payload.id,
705
+ result: result ?? {},
706
+ });
707
+ } catch (error) {
708
+ params.respond({
709
+ jsonrpc: "2.0",
710
+ id: payload.id,
711
+ error: {
712
+ code: -32603,
713
+ message: error instanceof Error ? error.message : String(error),
714
+ },
715
+ });
716
+ }
717
+ }
718
+
719
+ function createJsonRpcClient(settings: PluginSettings): JsonRpcClient {
720
+ if (settings.transport === "websocket") {
721
+ if (!settings.url) {
722
+ throw new Error("Codex websocket transport requires a url.");
723
+ }
724
+ return new WsJsonRpcClient(settings.url, settings.headers, settings.requestTimeoutMs);
725
+ }
726
+ return new StdioJsonRpcClient(settings.command, settings.args, settings.requestTimeoutMs);
727
+ }
728
+
729
+ async function initializeClient(params: {
730
+ client: JsonRpcClient;
731
+ settings: PluginSettings;
732
+ sessionKey?: string;
733
+ }): Promise<void> {
734
+ await params.client.request("initialize", {
735
+ protocolVersion: DEFAULT_PROTOCOL_VERSION,
736
+ clientInfo: { name: "openclaw-codex-app-server", version: "0.0.0-development" },
737
+ capabilities: { experimentalApi: true },
738
+ });
739
+ await params.client.notify("initialized", {});
740
+ if (params.sessionKey) {
741
+ await params.client
742
+ .request("session/update", {
743
+ sessionKey: params.sessionKey,
744
+ session_key: params.sessionKey,
745
+ })
746
+ .catch((error) => {
747
+ if (!isMethodUnavailableError(error, "session/update")) {
748
+ throw error;
749
+ }
750
+ });
751
+ }
752
+ }
753
+
754
+ async function requestWithFallbacks(params: {
755
+ client: JsonRpcClient;
756
+ methods: string[];
757
+ payloads: unknown[];
758
+ timeoutMs: number;
759
+ }): Promise<unknown> {
760
+ let lastError: unknown;
761
+ for (const method of params.methods) {
762
+ for (const payload of params.payloads) {
763
+ if (methodRequiresThreadId(method) && !payloadHasThreadId(payload)) {
764
+ throw new Error(`codex app server request missing threadId: ${method}`);
765
+ }
766
+ try {
767
+ return await params.client.request(method, payload, params.timeoutMs);
768
+ } catch (error) {
769
+ lastError = error;
770
+ if (!isMethodUnavailableError(error, method)) {
771
+ continue;
772
+ }
773
+ }
774
+ }
775
+ }
776
+ throw lastError instanceof Error ? lastError : new Error(String(lastError));
777
+ }
778
+
779
+ function buildThreadDiscoveryFilter(filter?: string, workspaceDir?: string): unknown[] {
780
+ return [
781
+ {
782
+ query: filter?.trim() || undefined,
783
+ cwd: workspaceDir,
784
+ limit: 50,
785
+ },
786
+ {
787
+ filter: filter?.trim() || undefined,
788
+ cwd: workspaceDir,
789
+ limit: 50,
790
+ },
791
+ {},
792
+ ];
793
+ }
794
+
795
+ function buildThreadResumePayloads(params: {
796
+ threadId: string;
797
+ model?: string;
798
+ cwd?: string;
799
+ serviceTier?: string | null;
800
+ }): Array<Record<string, unknown>> {
801
+ const base: Record<string, unknown> = { threadId: params.threadId };
802
+ if (params.model?.trim()) {
803
+ base.model = params.model.trim();
804
+ }
805
+ if (params.cwd?.trim()) {
806
+ base.cwd = params.cwd.trim();
807
+ }
808
+ if (params.serviceTier !== undefined) {
809
+ base.serviceTier = params.serviceTier;
810
+ }
811
+ return [base, { ...base, thread_id: params.threadId, threadId: undefined }].map((entry) => {
812
+ const next = { ...entry };
813
+ if (next.threadId === undefined) {
814
+ delete next.threadId;
815
+ }
816
+ return next;
817
+ });
818
+ }
819
+
820
+ function buildTurnInput(
821
+ prompt: string,
822
+ options?: { includeLegacyMessageVariant?: boolean },
823
+ ): unknown[] {
824
+ const variants: unknown[] = [[{ type: "text", text: prompt }]];
825
+ if (options?.includeLegacyMessageVariant !== false) {
826
+ variants.push([
827
+ {
828
+ type: "message",
829
+ role: "user",
830
+ content: [{ type: "input_text", text: prompt }],
831
+ },
832
+ ]);
833
+ }
834
+ return variants;
835
+ }
836
+
837
+ function buildCollaborationModeVariants(
838
+ collaborationMode: CollaborationMode,
839
+ ): Array<{ camel?: Record<string, unknown>; snake?: Record<string, unknown> }> {
840
+ const hasDeveloperInstructions = Object.hasOwn(
841
+ collaborationMode.settings ?? {},
842
+ "developerInstructions",
843
+ );
844
+ const normalizedSettings: Record<string, unknown> = {
845
+ ...(collaborationMode.settings?.model
846
+ ? { model: collaborationMode.settings.model }
847
+ : {}),
848
+ ...(collaborationMode.settings?.reasoningEffort
849
+ ? { reasoningEffort: collaborationMode.settings.reasoningEffort }
850
+ : {}),
851
+ ...(typeof collaborationMode.settings?.developerInstructions === "string"
852
+ ? collaborationMode.settings.developerInstructions.trim()
853
+ ? { developerInstructions: collaborationMode.settings.developerInstructions.trim() }
854
+ : {}
855
+ : {}),
856
+ ...(hasDeveloperInstructions &&
857
+ (collaborationMode.settings?.developerInstructions == null ||
858
+ collaborationMode.settings?.developerInstructions === "")
859
+ ? { developerInstructions: null }
860
+ : {}),
861
+ };
862
+ const variants: Array<{ camel?: Record<string, unknown>; snake?: Record<string, unknown> }> =
863
+ [];
864
+ if (Object.keys(normalizedSettings).length > 0) {
865
+ variants.push({
866
+ camel: {
867
+ mode: collaborationMode.mode,
868
+ settings: normalizedSettings,
869
+ },
870
+ snake: {
871
+ mode: collaborationMode.mode,
872
+ settings: {
873
+ ...(typeof normalizedSettings.model === "string"
874
+ ? { model: normalizedSettings.model }
875
+ : {}),
876
+ ...(typeof normalizedSettings.reasoningEffort === "string"
877
+ ? { reasoning_effort: normalizedSettings.reasoningEffort }
878
+ : {}),
879
+ ...(typeof normalizedSettings.developerInstructions === "string" ||
880
+ normalizedSettings.developerInstructions == null
881
+ ? { developer_instructions: normalizedSettings.developerInstructions }
882
+ : {}),
883
+ },
884
+ },
885
+ });
886
+ }
887
+ variants.push({
888
+ camel: {
889
+ mode: collaborationMode.mode,
890
+ },
891
+ snake: {
892
+ mode: collaborationMode.mode,
893
+ },
894
+ });
895
+ return variants;
896
+ }
897
+
898
+ function buildTurnStartPayloads(params: {
899
+ threadId: string;
900
+ prompt: string;
901
+ model?: string;
902
+ collaborationMode?: CollaborationMode;
903
+ }): unknown[] {
904
+ const payloads = buildTurnInput(params.prompt, {
905
+ includeLegacyMessageVariant: !params.collaborationMode,
906
+ }).flatMap((input) => {
907
+ const camel: Record<string, unknown> = {
908
+ threadId: params.threadId,
909
+ input,
910
+ };
911
+ const snake: Record<string, unknown> = {
912
+ thread_id: params.threadId,
913
+ input,
914
+ };
915
+ if (params.model?.trim()) {
916
+ camel.model = params.model.trim();
917
+ snake.model = params.model.trim();
918
+ }
919
+ if (!params.collaborationMode) {
920
+ return [camel, snake];
921
+ }
922
+ const collaborationVariants = buildCollaborationModeVariants(params.collaborationMode);
923
+ return [
924
+ ...collaborationVariants.flatMap((variant) => [
925
+ {
926
+ ...camel,
927
+ ...(variant.camel ? { collaborationMode: variant.camel } : {}),
928
+ },
929
+ {
930
+ ...snake,
931
+ ...(variant.snake ? { collaboration_mode: variant.snake } : {}),
932
+ },
933
+ ]),
934
+ camel,
935
+ snake,
936
+ ];
937
+ });
938
+ return payloads;
939
+ }
940
+
941
+ function normalizeEpochTimestamp(value: number | undefined): number | undefined {
942
+ if (typeof value !== "number" || !Number.isFinite(value)) {
943
+ return undefined;
944
+ }
945
+ return value < 1_000_000_000_000 ? value * 1_000 : value;
946
+ }
947
+
948
+ function extractThreadRecords(value: unknown): Record<string, unknown>[] {
949
+ if (Array.isArray(value)) {
950
+ return value.flatMap((entry) => extractThreadRecords(entry));
951
+ }
952
+ const record = asRecord(value);
953
+ if (!record) {
954
+ return [];
955
+ }
956
+ const directId = pickString(record, ["id", "threadId", "thread_id", "conversationId"]);
957
+ if (directId && !Array.isArray(record.items) && !Array.isArray(record.threads)) {
958
+ return [record];
959
+ }
960
+ const out: Record<string, unknown>[] = [];
961
+ for (const key of ["threads", "items", "data", "results"]) {
962
+ const nested = record[key];
963
+ if (Array.isArray(nested)) {
964
+ out.push(...nested.flatMap((entry) => extractThreadRecords(entry)));
965
+ }
966
+ }
967
+ return out;
968
+ }
969
+
970
+ function extractThreadsFromValue(value: unknown): ThreadSummary[] {
971
+ const items = extractThreadRecords(value);
972
+ const summaries = new Map<string, ThreadSummary>();
973
+ for (const record of items) {
974
+ const threadId =
975
+ pickString(record, ["threadId", "thread_id", "id", "conversationId", "conversation_id"]) ??
976
+ pickString(asRecord(record.thread) ?? {}, ["id", "threadId", "thread_id"]);
977
+ if (!threadId) {
978
+ continue;
979
+ }
980
+ const sessionRecord = asRecord(record.session);
981
+ summaries.set(threadId, {
982
+ threadId,
983
+ title:
984
+ pickString(record, ["title", "name", "headline"]) ??
985
+ pickString(sessionRecord ?? {}, ["title", "name"]),
986
+ summary:
987
+ pickString(record, ["summary", "preview", "snippet", "text"]) ??
988
+ dedupeJoinedText(collectText(record.messages ?? record.lastMessage ?? record.content)),
989
+ projectKey:
990
+ pickString(record, ["projectKey", "project_key", "cwd"]) ??
991
+ pickString(sessionRecord ?? {}, ["cwd", "projectKey", "project_key"]),
992
+ createdAt: normalizeEpochTimestamp(
993
+ pickNumber(record, ["createdAt", "created_at"]) ??
994
+ pickNumber(sessionRecord ?? {}, ["createdAt", "created_at"]),
995
+ ),
996
+ updatedAt: normalizeEpochTimestamp(
997
+ pickNumber(record, ["updatedAt", "updated_at", "lastActivityAt", "createdAt"]) ??
998
+ pickNumber(sessionRecord ?? {}, ["updatedAt", "updated_at", "lastActivityAt"]),
999
+ ),
1000
+ gitBranch:
1001
+ pickString(asRecord(record.gitInfo) ?? {}, ["branch"]) ??
1002
+ pickString(asRecord(record.git_info) ?? {}, ["branch"]) ??
1003
+ pickString(asRecord(sessionRecord?.gitInfo) ?? {}, ["branch"]) ??
1004
+ pickString(asRecord(sessionRecord?.git_info) ?? {}, ["branch"]),
1005
+ });
1006
+ }
1007
+ return [...summaries.values()].sort(
1008
+ (left, right) => (right.updatedAt ?? 0) - (left.updatedAt ?? 0),
1009
+ );
1010
+ }
1011
+
1012
+ function normalizeConversationRole(value: string | undefined): "user" | "assistant" | undefined {
1013
+ const normalized = value?.trim().toLowerCase();
1014
+ if (normalized === "user" || normalized === "usermessage") {
1015
+ return "user";
1016
+ }
1017
+ if (normalized === "assistant" || normalized === "agentmessage" || normalized === "assistantmessage") {
1018
+ return "assistant";
1019
+ }
1020
+ return undefined;
1021
+ }
1022
+
1023
+ function collectMessageText(record: Record<string, unknown>): string {
1024
+ return dedupeJoinedText([
1025
+ ...collectText(record.content),
1026
+ ...collectText(record.text),
1027
+ ...collectText(record.message),
1028
+ ...collectText(record.messages),
1029
+ ...collectText(record.input),
1030
+ ...collectText(record.output),
1031
+ ...collectText(record.parts),
1032
+ ]);
1033
+ }
1034
+
1035
+ function extractConversationMessages(
1036
+ value: unknown,
1037
+ ): Array<{ role: "user" | "assistant"; text: string }> {
1038
+ const out: Array<{ role: "user" | "assistant"; text: string }> = [];
1039
+ const visit = (node: unknown) => {
1040
+ if (Array.isArray(node)) {
1041
+ node.forEach((entry) => visit(entry));
1042
+ return;
1043
+ }
1044
+ const record = asRecord(node);
1045
+ if (!record) {
1046
+ return;
1047
+ }
1048
+ const role = normalizeConversationRole(
1049
+ pickString(record, ["role", "author", "speaker", "source", "type"]),
1050
+ );
1051
+ const text = collectMessageText(record);
1052
+ if (role && text) {
1053
+ out.push({ role, text });
1054
+ }
1055
+ for (const key of [
1056
+ "items",
1057
+ "messages",
1058
+ "content",
1059
+ "parts",
1060
+ "entries",
1061
+ "data",
1062
+ "results",
1063
+ "turns",
1064
+ "events",
1065
+ "item",
1066
+ "message",
1067
+ "thread",
1068
+ "response",
1069
+ "result",
1070
+ ]) {
1071
+ visit(record[key]);
1072
+ }
1073
+ };
1074
+ visit(value);
1075
+ return out;
1076
+ }
1077
+
1078
+ function extractThreadReplayFromReadResult(value: unknown): ThreadReplay {
1079
+ const messages = extractConversationMessages(value);
1080
+ let lastUserMessage: string | undefined;
1081
+ let lastAssistantMessage: string | undefined;
1082
+ for (let index = messages.length - 1; index >= 0; index -= 1) {
1083
+ const message = messages[index];
1084
+ if (!lastAssistantMessage && message?.role === "assistant") {
1085
+ lastAssistantMessage = message.text;
1086
+ }
1087
+ if (!lastUserMessage && message?.role === "user") {
1088
+ lastUserMessage = message.text;
1089
+ }
1090
+ if (lastUserMessage && lastAssistantMessage) {
1091
+ break;
1092
+ }
1093
+ }
1094
+ return { lastUserMessage, lastAssistantMessage };
1095
+ }
1096
+
1097
+ function normalizeApprovalFilePath(rawPath: string, workspaceDir?: string): string {
1098
+ const trimmed = rawPath.trim();
1099
+ if (!trimmed) {
1100
+ return "";
1101
+ }
1102
+ if (!path.isAbsolute(trimmed)) {
1103
+ return trimmed.replace(/\\/g, "/");
1104
+ }
1105
+ const root = workspaceDir?.trim();
1106
+ if (root && path.isAbsolute(root)) {
1107
+ const relative = path.relative(root, trimmed);
1108
+ if (relative && !relative.startsWith("..") && !path.isAbsolute(relative)) {
1109
+ return relative.replace(/\\/g, "/");
1110
+ }
1111
+ }
1112
+ return trimmed;
1113
+ }
1114
+
1115
+ function extractFileChangePathsFromReadResult(
1116
+ value: unknown,
1117
+ itemId: string,
1118
+ workspaceDir?: string,
1119
+ ): string[] {
1120
+ const out: string[] = [];
1121
+ const seen = new Set<string>();
1122
+ const targetId = itemId.trim();
1123
+ const visit = (node: unknown) => {
1124
+ if (Array.isArray(node)) {
1125
+ node.forEach((entry) => visit(entry));
1126
+ return;
1127
+ }
1128
+ const record = asRecord(node);
1129
+ if (!record) {
1130
+ return;
1131
+ }
1132
+ const item = asRecord(record.item) ?? record;
1133
+ const type = pickString(item, ["type"])?.trim().toLowerCase();
1134
+ const id = pickString(item, ["id", "itemId", "item_id"])?.trim();
1135
+ if (type === "filechange" && id === targetId) {
1136
+ const changes = Array.isArray(item.changes) ? item.changes : [];
1137
+ for (const changeValue of changes) {
1138
+ const change = asRecord(changeValue);
1139
+ const rawPath = pickString(change ?? {}, ["path"]);
1140
+ if (!rawPath) {
1141
+ continue;
1142
+ }
1143
+ const formatted = normalizeApprovalFilePath(rawPath, workspaceDir);
1144
+ if (!formatted || seen.has(formatted)) {
1145
+ continue;
1146
+ }
1147
+ seen.add(formatted);
1148
+ out.push(formatted);
1149
+ }
1150
+ return;
1151
+ }
1152
+ for (const key of ["turns", "items", "data", "results", "thread", "response", "result"]) {
1153
+ visit(record[key]);
1154
+ }
1155
+ };
1156
+ visit(value);
1157
+ return out;
1158
+ }
1159
+
1160
+ async function readFileChangePathsWithClient(params: {
1161
+ client: JsonRpcClient;
1162
+ settings: PluginSettings;
1163
+ threadId: string;
1164
+ itemId: string;
1165
+ workspaceDir?: string;
1166
+ }): Promise<string[]> {
1167
+ const result = await requestWithFallbacks({
1168
+ client: params.client,
1169
+ methods: ["thread/read"],
1170
+ payloads: [
1171
+ { threadId: params.threadId, includeTurns: true },
1172
+ { thread_id: params.threadId, include_turns: true },
1173
+ ],
1174
+ timeoutMs: params.settings.requestTimeoutMs,
1175
+ });
1176
+ return extractFileChangePathsFromReadResult(result, params.itemId, params.workspaceDir);
1177
+ }
1178
+
1179
+ function extractModelSummaries(value: unknown): ModelSummary[] {
1180
+ const out = new Map<string, ModelSummary>();
1181
+ const visit = (node: unknown) => {
1182
+ if (Array.isArray(node)) {
1183
+ node.forEach((entry) => visit(entry));
1184
+ return;
1185
+ }
1186
+ const record = asRecord(node);
1187
+ if (!record) {
1188
+ return;
1189
+ }
1190
+ const provider = pickString(record, ["provider", "providerId", "provider_id"]);
1191
+ const rawId =
1192
+ pickString(record, ["id", "model", "modelId", "model_id", "name", "slug"]) ??
1193
+ pickString(record, ["ref", "modelRef", "model_ref"]);
1194
+ if (rawId) {
1195
+ const id =
1196
+ provider && !rawId.includes("/") && !rawId.startsWith("@") ? `${provider}/${rawId}` : rawId;
1197
+ const existing = out.get(id);
1198
+ out.set(id, {
1199
+ id,
1200
+ label:
1201
+ pickString(record, ["label", "title", "displayName", "display_name"]) ?? existing?.label,
1202
+ description:
1203
+ pickString(record, ["description", "summary", "details"]) ?? existing?.description,
1204
+ current:
1205
+ pickBoolean(record, ["current", "selected", "isCurrent", "is_current", "active"]) ??
1206
+ existing?.current,
1207
+ });
1208
+ }
1209
+ for (const key of ["models", "items", "data", "results", "entries", "available"]) {
1210
+ visit(record[key]);
1211
+ }
1212
+ };
1213
+ visit(value);
1214
+ return [...out.values()].sort((left, right) => {
1215
+ if (left.current && !right.current) {
1216
+ return -1;
1217
+ }
1218
+ if (!left.current && right.current) {
1219
+ return 1;
1220
+ }
1221
+ return left.id.localeCompare(right.id);
1222
+ });
1223
+ }
1224
+
1225
+ function extractSkillSummaries(value: unknown): SkillSummary[] {
1226
+ const items: SkillSummary[] = [];
1227
+ const containers = Array.isArray(asRecord(value)?.data)
1228
+ ? (asRecord(value)?.data as unknown[])
1229
+ : Array.isArray(value)
1230
+ ? value
1231
+ : [];
1232
+ for (const containerValue of containers) {
1233
+ const container = asRecord(containerValue);
1234
+ if (!container) {
1235
+ continue;
1236
+ }
1237
+ const cwd = pickString(container, ["cwd", "path", "projectRoot"]);
1238
+ const skills = Array.isArray(container.skills) ? container.skills : [];
1239
+ for (const skillValue of skills) {
1240
+ const skill = asRecord(skillValue);
1241
+ if (!skill) {
1242
+ continue;
1243
+ }
1244
+ const name = pickString(skill, ["name", "id"]);
1245
+ if (!name) {
1246
+ continue;
1247
+ }
1248
+ const iface = asRecord(skill.interface);
1249
+ items.push({
1250
+ cwd,
1251
+ name,
1252
+ description:
1253
+ pickString(skill, ["description", "shortDescription"]) ??
1254
+ pickString(iface ?? {}, ["shortDescription", "description"]),
1255
+ enabled: pickBoolean(skill, ["enabled", "active", "isEnabled", "is_enabled"]),
1256
+ });
1257
+ }
1258
+ }
1259
+ return items.sort((left, right) => left.name.localeCompare(right.name));
1260
+ }
1261
+
1262
+ function extractExperimentalFeatureSummaries(value: unknown): ExperimentalFeatureSummary[] {
1263
+ const items: ExperimentalFeatureSummary[] = [];
1264
+ const entries = Array.isArray(asRecord(value)?.data)
1265
+ ? (asRecord(value)?.data as unknown[])
1266
+ : Array.isArray(value)
1267
+ ? value
1268
+ : [];
1269
+ for (const entryValue of entries) {
1270
+ const entry = asRecord(entryValue);
1271
+ if (!entry) {
1272
+ continue;
1273
+ }
1274
+ const name = pickString(entry, ["name", "id", "key"]);
1275
+ if (!name) {
1276
+ continue;
1277
+ }
1278
+ items.push({
1279
+ name,
1280
+ stage: pickString(entry, ["stage", "status"]),
1281
+ displayName: pickString(entry, ["displayName", "display_name", "title"]),
1282
+ description: pickString(entry, ["description", "summary", "announcement"]),
1283
+ enabled: pickBoolean(entry, ["enabled", "active", "isEnabled", "is_enabled"]),
1284
+ defaultEnabled: pickBoolean(entry, ["defaultEnabled", "default_enabled", "enabledByDefault"]),
1285
+ });
1286
+ }
1287
+ return items.sort((left, right) => left.name.localeCompare(right.name));
1288
+ }
1289
+
1290
+ function extractMcpServerSummaries(value: unknown): McpServerSummary[] {
1291
+ const items: McpServerSummary[] = [];
1292
+ const entries = Array.isArray(asRecord(value)?.data)
1293
+ ? (asRecord(value)?.data as unknown[])
1294
+ : Array.isArray(value)
1295
+ ? value
1296
+ : [];
1297
+ for (const entryValue of entries) {
1298
+ const entry = asRecord(entryValue);
1299
+ if (!entry) {
1300
+ continue;
1301
+ }
1302
+ const name = pickString(entry, ["name", "id"]);
1303
+ if (!name) {
1304
+ continue;
1305
+ }
1306
+ const tools = asRecord(entry.tools);
1307
+ items.push({
1308
+ name,
1309
+ authStatus: pickString(entry, ["authStatus", "auth_status", "status"]),
1310
+ toolCount: tools ? Object.keys(tools).length : Array.isArray(entry.tools) ? entry.tools.length : 0,
1311
+ resourceCount: Array.isArray(entry.resources) ? entry.resources.length : 0,
1312
+ resourceTemplateCount: Array.isArray(entry.resourceTemplates)
1313
+ ? entry.resourceTemplates.length
1314
+ : Array.isArray(entry.resource_templates)
1315
+ ? entry.resource_templates.length
1316
+ : 0,
1317
+ });
1318
+ }
1319
+ return items.sort((left, right) => left.name.localeCompare(right.name));
1320
+ }
1321
+
1322
+ function summarizeSandboxPolicy(value: unknown): string | undefined {
1323
+ if (typeof value === "string") {
1324
+ return value.trim() || undefined;
1325
+ }
1326
+ const record = asRecord(value);
1327
+ if (!record) {
1328
+ return undefined;
1329
+ }
1330
+ if ("dangerFullAccess" in record || "danger_full_access" in record) {
1331
+ return "danger-full-access";
1332
+ }
1333
+ if ("readOnly" in record || "read_only" in record) {
1334
+ return "read-only";
1335
+ }
1336
+ if ("workspaceWrite" in record || "workspace_write" in record) {
1337
+ return "workspace-write";
1338
+ }
1339
+ if ("externalSandbox" in record || "external_sandbox" in record) {
1340
+ return "external-sandbox";
1341
+ }
1342
+ return pickString(record, ["mode", "type", "kind", "name"]);
1343
+ }
1344
+
1345
+ function extractThreadState(value: unknown): ThreadState {
1346
+ return {
1347
+ threadId:
1348
+ extractIds(value).threadId ??
1349
+ findFirstNestedString(value, ["threadId", "thread_id", "id", "conversationId"]) ??
1350
+ "",
1351
+ threadName: findFirstNestedString(value, ["threadName", "thread_name", "name", "title"]),
1352
+ model: findFirstNestedString(value, ["model", "modelId", "model_id"]),
1353
+ modelProvider: findFirstNestedString(value, [
1354
+ "modelProvider",
1355
+ "model_provider",
1356
+ "provider",
1357
+ "providerId",
1358
+ "provider_id",
1359
+ ]),
1360
+ serviceTier: findFirstNestedString(value, ["serviceTier", "service_tier"]),
1361
+ cwd: findFirstNestedString(value, ["cwd", "workdir", "directory"]),
1362
+ approvalPolicy: findFirstNestedString(value, ["approvalPolicy", "approval_policy"]),
1363
+ sandbox: summarizeSandboxPolicy(findFirstNestedValue(value, ["sandbox", "sandbox_policy"])),
1364
+ reasoningEffort: findFirstNestedString(value, ["reasoningEffort", "reasoning_effort"]),
1365
+ };
1366
+ }
1367
+
1368
+ function normalizeEpochMilliseconds(value: number | undefined): number | undefined {
1369
+ if (typeof value !== "number" || !Number.isFinite(value)) {
1370
+ return undefined;
1371
+ }
1372
+ const abs = Math.abs(value);
1373
+ if (abs < 100_000_000_000) {
1374
+ return Math.round(value * 1_000);
1375
+ }
1376
+ if (abs > 100_000_000_000_000) {
1377
+ return Math.round(value / 1_000);
1378
+ }
1379
+ return Math.round(value);
1380
+ }
1381
+
1382
+ function formatRateLimitWindowName(params: {
1383
+ limitId?: string;
1384
+ limitName?: string;
1385
+ windowKey: "primary" | "secondary";
1386
+ windowMinutes?: number;
1387
+ }): string {
1388
+ const rawId = params.limitId?.trim();
1389
+ const rawName = params.limitName?.trim();
1390
+ const minutes = params.windowMinutes;
1391
+ let windowLabel: string;
1392
+ if (minutes === 300) {
1393
+ windowLabel = "5h limit";
1394
+ } else if (minutes === 10080) {
1395
+ windowLabel = "Weekly limit";
1396
+ } else if (minutes === 43200) {
1397
+ windowLabel = "Monthly limit";
1398
+ } else if (typeof minutes === "number" && minutes > 0) {
1399
+ if (minutes % 1440 === 0) {
1400
+ windowLabel = `${Math.round(minutes / 1440)}d limit`;
1401
+ } else if (minutes % 60 === 0) {
1402
+ windowLabel = `${Math.round(minutes / 60)}h limit`;
1403
+ } else {
1404
+ windowLabel = `${minutes}m limit`;
1405
+ }
1406
+ } else {
1407
+ windowLabel = params.windowKey === "primary" ? "Primary limit" : "Secondary limit";
1408
+ }
1409
+ if (!rawId || rawId.toLowerCase() === "codex") {
1410
+ return windowLabel;
1411
+ }
1412
+ return `${rawName ?? rawId} ${windowLabel}`.trim();
1413
+ }
1414
+
1415
+ function extractRateLimitSummaries(value: unknown): RateLimitSummary[] {
1416
+ const out = new Map<string, RateLimitSummary>();
1417
+ const addWindow = (
1418
+ windowValue: unknown,
1419
+ params: { limitId?: string; limitName?: string; windowKey: "primary" | "secondary" },
1420
+ ) => {
1421
+ const window = asRecord(windowValue);
1422
+ if (!window) {
1423
+ return;
1424
+ }
1425
+ const usedPercent = pickFiniteNumber(window, ["usedPercent", "used_percent"]);
1426
+ const windowMinutes = pickFiniteNumber(window, [
1427
+ "windowDurationMins",
1428
+ "window_duration_mins",
1429
+ "windowMinutes",
1430
+ "window_minutes",
1431
+ ]);
1432
+ const name = formatRateLimitWindowName({
1433
+ limitId: params.limitId,
1434
+ limitName: params.limitName,
1435
+ windowKey: params.windowKey,
1436
+ windowMinutes,
1437
+ });
1438
+ out.set(name, {
1439
+ name,
1440
+ limitId: params.limitId,
1441
+ usedPercent,
1442
+ remaining:
1443
+ typeof usedPercent === "number" ? Math.max(0, Math.round(100 - usedPercent)) : undefined,
1444
+ resetAt: normalizeEpochMilliseconds(
1445
+ pickNumber(window, ["resetsAt", "resets_at", "resetAt", "reset_at"]),
1446
+ ),
1447
+ windowSeconds: typeof windowMinutes === "number" ? Math.round(windowMinutes * 60) : undefined,
1448
+ windowMinutes,
1449
+ });
1450
+ };
1451
+ const visit = (node: unknown) => {
1452
+ if (Array.isArray(node)) {
1453
+ node.forEach((entry) => visit(entry));
1454
+ return;
1455
+ }
1456
+ const record = asRecord(node);
1457
+ if (!record) {
1458
+ return;
1459
+ }
1460
+ if ("primary" in record || "secondary" in record) {
1461
+ const limitId = pickString(record, ["limitId", "limit_id", "id"]);
1462
+ const limitName = pickString(record, ["limitName", "limit_name", "name", "label"]);
1463
+ addWindow(record.primary, { limitId, limitName, windowKey: "primary" });
1464
+ addWindow(record.secondary, { limitId, limitName, windowKey: "secondary" });
1465
+ }
1466
+ if (record.rateLimitsByLimitId && typeof record.rateLimitsByLimitId === "object") {
1467
+ for (const [limitId, snapshot] of Object.entries(record.rateLimitsByLimitId)) {
1468
+ const snapshotRecord = asRecord(snapshot);
1469
+ if (!snapshotRecord) {
1470
+ continue;
1471
+ }
1472
+ const limitName = pickString(snapshotRecord, ["limitName", "limit_name", "name", "label"]);
1473
+ addWindow(snapshotRecord.primary, { limitId, limitName, windowKey: "primary" });
1474
+ addWindow(snapshotRecord.secondary, { limitId, limitName, windowKey: "secondary" });
1475
+ }
1476
+ }
1477
+ const remaining = pickFiniteNumber(record, [
1478
+ "remaining",
1479
+ "remainingCount",
1480
+ "remaining_count",
1481
+ "available",
1482
+ ]);
1483
+ const limit = pickFiniteNumber(record, ["limit", "max", "quota", "capacity"]);
1484
+ const used = pickFiniteNumber(record, ["used", "consumed", "count"]);
1485
+ const resetAt = pickNumber(record, [
1486
+ "resetAt",
1487
+ "reset_at",
1488
+ "resetsAt",
1489
+ "resets_at",
1490
+ "nextResetAt",
1491
+ ]);
1492
+ const windowSeconds = pickFiniteNumber(record, [
1493
+ "windowSeconds",
1494
+ "window_seconds",
1495
+ "resetInSeconds",
1496
+ "retryAfterSeconds",
1497
+ ]);
1498
+ const name =
1499
+ pickString(record, ["name", "label", "scope", "resource", "model", "id"]) ??
1500
+ (typeof remaining === "number" ||
1501
+ typeof limit === "number" ||
1502
+ typeof used === "number" ||
1503
+ typeof resetAt === "number"
1504
+ ? `limit-${out.size + 1}`
1505
+ : undefined);
1506
+ if (name) {
1507
+ const existing = out.get(name);
1508
+ out.set(name, {
1509
+ name,
1510
+ limitId: existing?.limitId,
1511
+ remaining: remaining ?? existing?.remaining,
1512
+ limit: limit ?? existing?.limit,
1513
+ used: used ?? existing?.used,
1514
+ usedPercent: existing?.usedPercent,
1515
+ resetAt: normalizeEpochMilliseconds(resetAt) ?? existing?.resetAt,
1516
+ windowSeconds: windowSeconds ?? existing?.windowSeconds,
1517
+ windowMinutes: existing?.windowMinutes,
1518
+ });
1519
+ }
1520
+ for (const key of [
1521
+ "limits",
1522
+ "items",
1523
+ "data",
1524
+ "results",
1525
+ "entries",
1526
+ "buckets",
1527
+ "rateLimits",
1528
+ "rate_limits",
1529
+ "rateLimitsByLimitId",
1530
+ "rate_limits_by_limit_id",
1531
+ ]) {
1532
+ visit(record[key]);
1533
+ }
1534
+ };
1535
+ visit(value);
1536
+ return [...out.values()].sort((left, right) => left.name.localeCompare(right.name));
1537
+ }
1538
+
1539
+ function extractAccountSummary(value: unknown): AccountSummary {
1540
+ const root = asRecord(value) ?? {};
1541
+ const account = asRecord(findFirstNestedValue(value, ["account"])) ?? asRecord(root.account) ?? undefined;
1542
+ const type = pickString(account ?? {}, ["type"]);
1543
+ return {
1544
+ type: type === "apiKey" || type === "chatgpt" ? type : undefined,
1545
+ email: pickString(account ?? {}, ["email"]),
1546
+ planType: pickString(account ?? {}, ["planType", "plan_type"]),
1547
+ requiresOpenaiAuth: pickBoolean(root, ["requiresOpenaiAuth", "requires_openai_auth"]),
1548
+ };
1549
+ }
1550
+
1551
+ function extractReviewTextFromNotification(method: string, params: unknown): string | undefined {
1552
+ const methodLower = method.trim().toLowerCase();
1553
+ if (methodLower !== "item/completed" && methodLower !== "item/started") {
1554
+ return undefined;
1555
+ }
1556
+ const item = asRecord(asRecord(params)?.item);
1557
+ const itemType = pickString(item ?? {}, ["type"])?.trim().toLowerCase();
1558
+ if (itemType !== "exitedreviewmode") {
1559
+ return undefined;
1560
+ }
1561
+ return pickString(item ?? {}, ["review"]);
1562
+ }
1563
+
1564
+ function extractAssistantItemId(value: unknown): string | undefined {
1565
+ const record = asRecord(value);
1566
+ if (!record) {
1567
+ return undefined;
1568
+ }
1569
+ const item = asRecord(record.item) ?? record;
1570
+ return pickString(item, ["id", "itemId", "item_id", "messageId", "message_id"]);
1571
+ }
1572
+
1573
+ function extractAssistantTextFromItemPayload(
1574
+ value: unknown,
1575
+ options?: { streaming?: boolean },
1576
+ ): string {
1577
+ const record = asRecord(value);
1578
+ if (!record) {
1579
+ return "";
1580
+ }
1581
+ const item = asRecord(record.item) ?? record;
1582
+ const itemType = pickString(item, ["type"])?.toLowerCase();
1583
+ if (itemType !== "agentmessage") {
1584
+ return "";
1585
+ }
1586
+ return options?.streaming
1587
+ ? collectStreamingText(item)
1588
+ : (pickString(item, ["text"], { trim: false }) ?? collectStreamingText(item));
1589
+ }
1590
+
1591
+ function extractAssistantNotificationText(
1592
+ method: string,
1593
+ params: unknown,
1594
+ ): { mode: "delta" | "snapshot" | "ignore"; text: string; itemId?: string } {
1595
+ const methodLower = method.trim().toLowerCase();
1596
+ if (methodLower === "item/agentmessage/delta") {
1597
+ return {
1598
+ mode: "delta",
1599
+ text: collectStreamingText(params),
1600
+ itemId: extractAssistantItemId(params),
1601
+ };
1602
+ }
1603
+ if (methodLower === "item/completed") {
1604
+ return {
1605
+ mode: "snapshot",
1606
+ text: extractAssistantTextFromItemPayload(params),
1607
+ itemId: extractAssistantItemId(params),
1608
+ };
1609
+ }
1610
+ return { mode: "ignore", text: "" };
1611
+ }
1612
+
1613
+ function extractPlanDeltaNotification(value: unknown): { itemId?: string; delta: string } {
1614
+ return {
1615
+ itemId: extractAssistantItemId(value),
1616
+ delta: collectStreamingText(value),
1617
+ };
1618
+ }
1619
+
1620
+ function extractTurnPlanUpdate(value: unknown): {
1621
+ explanation?: string;
1622
+ steps: TurnResult["planArtifact"] extends infer T ? (T extends { steps: infer S } ? S : never) : never;
1623
+ } {
1624
+ const record = asRecord(value);
1625
+ const planRecord = asRecord(record?.plan);
1626
+ const rawPlan = Array.isArray(record?.plan)
1627
+ ? record.plan
1628
+ : Array.isArray(planRecord?.steps)
1629
+ ? planRecord.steps
1630
+ : [];
1631
+ const steps = rawPlan
1632
+ .map((entry) => {
1633
+ const stepRecord = asRecord(entry);
1634
+ const step = pickString(stepRecord ?? {}, ["step", "title", "text"]);
1635
+ const statusRaw =
1636
+ pickString(stepRecord ?? {}, ["status"], { trim: true })?.toLowerCase() ?? "pending";
1637
+ if (!step) {
1638
+ return null;
1639
+ }
1640
+ const status =
1641
+ statusRaw === "inprogress" || statusRaw === "in_progress"
1642
+ ? "inProgress"
1643
+ : statusRaw === "completed"
1644
+ ? "completed"
1645
+ : "pending";
1646
+ return { step, status } as const;
1647
+ })
1648
+ .filter(Boolean) as Array<{ step: string; status: "pending" | "inProgress" | "completed" }>;
1649
+ return {
1650
+ explanation:
1651
+ pickString(planRecord ?? {}, ["explanation"], { trim: true }) ??
1652
+ pickString(record ?? {}, ["explanation"], { trim: true }) ??
1653
+ findFirstNestedString(value, ["explanation"]),
1654
+ steps,
1655
+ };
1656
+ }
1657
+
1658
+ function extractCompletedPlanText(value: unknown): { itemId?: string; text?: string } {
1659
+ const record = asRecord(value);
1660
+ if (!record) {
1661
+ return {};
1662
+ }
1663
+ const item = asRecord(record.item) ?? record;
1664
+ const itemType = pickString(item, ["type"])?.toLowerCase();
1665
+ if (itemType !== "plan") {
1666
+ return {};
1667
+ }
1668
+ return {
1669
+ itemId: extractAssistantItemId(item),
1670
+ text: pickString(item, ["text"], { trim: false }) ?? collectStreamingText(item),
1671
+ };
1672
+ }
1673
+
1674
+ function extractThreadTokenUsageSnapshot(value: unknown): ContextUsageSnapshot | undefined {
1675
+ const root =
1676
+ asRecord(findFirstNestedValue(value, ["tokenUsage", "token_usage", "info"])) ?? asRecord(value);
1677
+ if (!root) {
1678
+ return undefined;
1679
+ }
1680
+ const currentUsage =
1681
+ asRecord(findFirstNestedValue(root, ["last", "last_token_usage"])) ??
1682
+ asRecord(root.last) ??
1683
+ asRecord(root.last_token_usage) ??
1684
+ asRecord(findFirstNestedValue(root, ["total", "total_token_usage"])) ??
1685
+ asRecord(root.total) ??
1686
+ asRecord(root.total_token_usage);
1687
+ const totalTokens = pickFiniteNumber(currentUsage ?? {}, ["totalTokens", "total_tokens"]);
1688
+ const inputTokens = pickFiniteNumber(currentUsage ?? {}, ["inputTokens", "input_tokens"]);
1689
+ const cachedInputTokens = pickFiniteNumber(currentUsage ?? {}, [
1690
+ "cachedInputTokens",
1691
+ "cached_input_tokens",
1692
+ ]);
1693
+ const outputTokens = pickFiniteNumber(currentUsage ?? {}, ["outputTokens", "output_tokens"]);
1694
+ const reasoningOutputTokens = pickFiniteNumber(currentUsage ?? {}, [
1695
+ "reasoningOutputTokens",
1696
+ "reasoning_output_tokens",
1697
+ ]);
1698
+ const contextWindow = pickFiniteNumber(root, ["modelContextWindow", "model_context_window"]);
1699
+ if (
1700
+ totalTokens === undefined &&
1701
+ inputTokens === undefined &&
1702
+ cachedInputTokens === undefined &&
1703
+ outputTokens === undefined &&
1704
+ reasoningOutputTokens === undefined &&
1705
+ contextWindow === undefined
1706
+ ) {
1707
+ return undefined;
1708
+ }
1709
+ const remainingTokens =
1710
+ typeof contextWindow === "number" && typeof totalTokens === "number"
1711
+ ? Math.max(0, contextWindow - totalTokens)
1712
+ : undefined;
1713
+ const remainingPercent =
1714
+ typeof contextWindow === "number" && contextWindow > 0 && typeof remainingTokens === "number"
1715
+ ? Math.max(0, Math.min(100, Math.round((remainingTokens / contextWindow) * 100)))
1716
+ : undefined;
1717
+ return {
1718
+ totalTokens,
1719
+ inputTokens,
1720
+ cachedInputTokens,
1721
+ outputTokens,
1722
+ reasoningOutputTokens,
1723
+ contextWindow,
1724
+ remainingTokens,
1725
+ remainingPercent,
1726
+ };
1727
+ }
1728
+
1729
+ function extractContextCompactionProgress(
1730
+ method: string,
1731
+ params: unknown,
1732
+ ): { phase: "started" | "completed"; itemId?: string } | undefined {
1733
+ const methodLower = method.trim().toLowerCase();
1734
+ if (methodLower === "thread/compacted") {
1735
+ return { phase: "completed" };
1736
+ }
1737
+ if (methodLower !== "item/started" && methodLower !== "item/completed") {
1738
+ return undefined;
1739
+ }
1740
+ const item = asRecord(asRecord(params)?.item);
1741
+ const itemType = pickString(item ?? {}, ["type"])
1742
+ ?.trim()
1743
+ .toLowerCase()
1744
+ .replace(/[^a-z]/g, "");
1745
+ if (itemType !== "contextcompaction") {
1746
+ return undefined;
1747
+ }
1748
+ return {
1749
+ phase: methodLower === "item/started" ? "started" : "completed",
1750
+ itemId: extractAssistantItemId(item),
1751
+ };
1752
+ }
1753
+
1754
+ function mapPendingInputResponse(params: {
1755
+ methodLower: string;
1756
+ requestParams: unknown;
1757
+ response: unknown;
1758
+ options: string[];
1759
+ actions: PendingInputAction[];
1760
+ timedOut: boolean;
1761
+ }): unknown {
1762
+ const { methodLower, response, options, actions, timedOut } = params;
1763
+ if (methodLower.includes("requestapproval")) {
1764
+ if (timedOut) {
1765
+ return { decision: "cancel" };
1766
+ }
1767
+ const record = asRecord(response);
1768
+ const index = typeof record?.index === "number" ? record.index : undefined;
1769
+ const action = index != null ? actions[index] : undefined;
1770
+ if (action?.kind === "approval") {
1771
+ return {
1772
+ decision: action.responseDecision,
1773
+ ...(action.proposedExecpolicyAmendment
1774
+ ? { proposedExecpolicyAmendment: action.proposedExecpolicyAmendment }
1775
+ : {}),
1776
+ };
1777
+ }
1778
+ const selected =
1779
+ (index != null
1780
+ ? action?.kind === "option"
1781
+ ? action.value
1782
+ : options[index]
1783
+ : undefined) ??
1784
+ pickString(record ?? {}, ["option", "text", "value", "label"]);
1785
+ return { decision: selected || "decline" };
1786
+ }
1787
+ if (timedOut) {
1788
+ return { cancelled: true, reason: "timeout" };
1789
+ }
1790
+ return response;
1791
+ }
1792
+
1793
+ type PendingInputQueueEntry = {
1794
+ state: PendingInputState;
1795
+ options: string[];
1796
+ actions: PendingInputAction[];
1797
+ methodLower: string;
1798
+ timedOut: boolean;
1799
+ timeoutHandle?: ReturnType<typeof setTimeout>;
1800
+ response: Promise<unknown>;
1801
+ resolveResponse: (value: unknown) => void;
1802
+ };
1803
+
1804
+ function createPendingInputCoordinator(params: {
1805
+ inputTimeoutMs: number;
1806
+ onPendingInput?: (state: PendingInputState | null) => Promise<void> | void;
1807
+ onActivated?: () => void;
1808
+ onCleared?: () => void;
1809
+ }) {
1810
+ let current: PendingInputQueueEntry | null = null;
1811
+ const queued: PendingInputQueueEntry[] = [];
1812
+
1813
+ const presentNext = async () => {
1814
+ if (current || queued.length === 0) {
1815
+ return;
1816
+ }
1817
+ const next = queued.shift();
1818
+ if (!next) {
1819
+ return;
1820
+ }
1821
+ current = next;
1822
+ params.onActivated?.();
1823
+ await params.onPendingInput?.(next.state);
1824
+ next.timeoutHandle = setTimeout(() => {
1825
+ if (current?.state.requestId !== next.state.requestId) {
1826
+ return;
1827
+ }
1828
+ next.timedOut = true;
1829
+ void settleCurrent({ text: "" });
1830
+ }, params.inputTimeoutMs);
1831
+ };
1832
+
1833
+ const clearCurrent = async () => {
1834
+ const active = current;
1835
+ if (!active) {
1836
+ return;
1837
+ }
1838
+ current = null;
1839
+ if (active.timeoutHandle) {
1840
+ clearTimeout(active.timeoutHandle);
1841
+ }
1842
+ params.onCleared?.();
1843
+ await params.onPendingInput?.(null);
1844
+ await presentNext();
1845
+ };
1846
+
1847
+ const settleCurrent = async (value: unknown) => {
1848
+ const active = current;
1849
+ if (!active) {
1850
+ return false;
1851
+ }
1852
+ current = null;
1853
+ if (active.timeoutHandle) {
1854
+ clearTimeout(active.timeoutHandle);
1855
+ }
1856
+ params.onCleared?.();
1857
+ await params.onPendingInput?.(null);
1858
+ active.resolveResponse(value);
1859
+ await presentNext();
1860
+ return true;
1861
+ };
1862
+
1863
+ return {
1864
+ enqueue(
1865
+ entry: Omit<
1866
+ PendingInputQueueEntry,
1867
+ "timedOut" | "timeoutHandle" | "response" | "resolveResponse"
1868
+ >,
1869
+ ) {
1870
+ let resolveResponse: (value: unknown) => void = () => undefined;
1871
+ const queuedEntry: PendingInputQueueEntry = {
1872
+ ...entry,
1873
+ timedOut: false,
1874
+ response: new Promise<unknown>((resolve) => {
1875
+ resolveResponse = resolve;
1876
+ }),
1877
+ resolveResponse: (value) => resolveResponse(value),
1878
+ };
1879
+ queued.push(queuedEntry);
1880
+ void presentNext();
1881
+ return queuedEntry;
1882
+ },
1883
+ current() {
1884
+ return current;
1885
+ },
1886
+ async settleCurrent(value: unknown) {
1887
+ return settleCurrent(value);
1888
+ },
1889
+ async clearCurrent() {
1890
+ await clearCurrent();
1891
+ },
1892
+ };
1893
+ }
1894
+
1895
+ async function withInitializedClient<T>(
1896
+ params: {
1897
+ settings: PluginSettings;
1898
+ sessionKey?: string;
1899
+ },
1900
+ callback: (args: { client: JsonRpcClient; settings: PluginSettings }) => Promise<T>,
1901
+ ): Promise<T> {
1902
+ const client = createJsonRpcClient(params.settings);
1903
+ try {
1904
+ await client.connect();
1905
+ await initializeClient({
1906
+ client,
1907
+ settings: params.settings,
1908
+ sessionKey: params.sessionKey,
1909
+ });
1910
+ return await callback({ client, settings: params.settings });
1911
+ } finally {
1912
+ await client.close().catch(() => undefined);
1913
+ }
1914
+ }
1915
+
1916
+ export function isMissingThreadError(error: unknown): boolean {
1917
+ const message = error instanceof Error ? error.message : String(error);
1918
+ const normalized = message.trim().toLowerCase();
1919
+ return (
1920
+ normalized.includes("no rollout found for thread id") ||
1921
+ normalized.includes("thread not found") ||
1922
+ normalized.includes("no thread found") ||
1923
+ normalized.includes("unknown thread id")
1924
+ );
1925
+ }
1926
+
1927
+ export class CodexAppServerClient {
1928
+ constructor(
1929
+ private readonly settings: PluginSettings,
1930
+ private readonly logger: PluginLogger,
1931
+ ) {}
1932
+
1933
+ async listThreads(params: {
1934
+ sessionKey?: string;
1935
+ workspaceDir?: string;
1936
+ filter?: string;
1937
+ }): Promise<ThreadSummary[]> {
1938
+ return await withInitializedClient(
1939
+ { settings: this.settings, sessionKey: params.sessionKey },
1940
+ async ({ client, settings }) => {
1941
+ const result = await requestWithFallbacks({
1942
+ client,
1943
+ methods: ["thread/list", "thread/loaded/list"],
1944
+ payloads: buildThreadDiscoveryFilter(params.filter, params.workspaceDir),
1945
+ timeoutMs: settings.requestTimeoutMs,
1946
+ });
1947
+ return extractThreadsFromValue(result);
1948
+ },
1949
+ );
1950
+ }
1951
+
1952
+ async listModels(params: { sessionKey?: string }): Promise<ModelSummary[]> {
1953
+ return await withInitializedClient(
1954
+ { settings: this.settings, sessionKey: params.sessionKey },
1955
+ async ({ client, settings }) => {
1956
+ const result = await requestWithFallbacks({
1957
+ client,
1958
+ methods: ["model/list"],
1959
+ payloads: [{}],
1960
+ timeoutMs: settings.requestTimeoutMs,
1961
+ });
1962
+ return extractModelSummaries(result);
1963
+ },
1964
+ );
1965
+ }
1966
+
1967
+ async listSkills(params: { sessionKey?: string; workspaceDir?: string }): Promise<SkillSummary[]> {
1968
+ return await withInitializedClient(
1969
+ { settings: this.settings, sessionKey: params.sessionKey },
1970
+ async ({ client, settings }) => {
1971
+ const result = await requestWithFallbacks({
1972
+ client,
1973
+ methods: ["skills/list"],
1974
+ payloads: [
1975
+ {
1976
+ cwds: params.workspaceDir ? [params.workspaceDir] : undefined,
1977
+ },
1978
+ {
1979
+ cwd: params.workspaceDir,
1980
+ },
1981
+ ],
1982
+ timeoutMs: settings.requestTimeoutMs,
1983
+ });
1984
+ return extractSkillSummaries(result);
1985
+ },
1986
+ );
1987
+ }
1988
+
1989
+ async listExperimentalFeatures(params: {
1990
+ sessionKey?: string;
1991
+ }): Promise<ExperimentalFeatureSummary[]> {
1992
+ return await withInitializedClient(
1993
+ { settings: this.settings, sessionKey: params.sessionKey },
1994
+ async ({ client, settings }) => {
1995
+ const result = await requestWithFallbacks({
1996
+ client,
1997
+ methods: ["experimentalFeature/list"],
1998
+ payloads: [{ limit: 100 }, {}],
1999
+ timeoutMs: settings.requestTimeoutMs,
2000
+ });
2001
+ return extractExperimentalFeatureSummaries(result);
2002
+ },
2003
+ );
2004
+ }
2005
+
2006
+ async listMcpServers(params: { sessionKey?: string }): Promise<McpServerSummary[]> {
2007
+ return await withInitializedClient(
2008
+ { settings: this.settings, sessionKey: params.sessionKey },
2009
+ async ({ client, settings }) => {
2010
+ const result = await requestWithFallbacks({
2011
+ client,
2012
+ methods: ["mcpServerStatus/list"],
2013
+ payloads: [{ limit: 100 }, {}],
2014
+ timeoutMs: settings.requestTimeoutMs,
2015
+ });
2016
+ return extractMcpServerSummaries(result);
2017
+ },
2018
+ );
2019
+ }
2020
+
2021
+ async readRateLimits(params: { sessionKey?: string }): Promise<RateLimitSummary[]> {
2022
+ return await withInitializedClient(
2023
+ { settings: this.settings, sessionKey: params.sessionKey },
2024
+ async ({ client, settings }) => {
2025
+ const result = await requestWithFallbacks({
2026
+ client,
2027
+ methods: ["account/rateLimits/read"],
2028
+ payloads: [{}],
2029
+ timeoutMs: settings.requestTimeoutMs,
2030
+ });
2031
+ return extractRateLimitSummaries(result);
2032
+ },
2033
+ );
2034
+ }
2035
+
2036
+ async readAccount(params: { sessionKey?: string }): Promise<AccountSummary> {
2037
+ return await withInitializedClient(
2038
+ { settings: this.settings, sessionKey: params.sessionKey },
2039
+ async ({ client, settings }) => {
2040
+ const result = await requestWithFallbacks({
2041
+ client,
2042
+ methods: ["account/read"],
2043
+ payloads: [{ refreshToken: false }, { refresh_token: false }, {}],
2044
+ timeoutMs: settings.requestTimeoutMs,
2045
+ });
2046
+ return extractAccountSummary(result);
2047
+ },
2048
+ );
2049
+ }
2050
+
2051
+ async readThreadState(params: { sessionKey?: string; threadId: string }): Promise<ThreadState> {
2052
+ return await withInitializedClient(
2053
+ { settings: this.settings, sessionKey: params.sessionKey },
2054
+ async ({ client, settings }) => {
2055
+ try {
2056
+ const result = await requestWithFallbacks({
2057
+ client,
2058
+ methods: ["thread/resume"],
2059
+ payloads: buildThreadResumePayloads({ threadId: params.threadId }),
2060
+ timeoutMs: settings.requestTimeoutMs,
2061
+ });
2062
+ return extractThreadState(result);
2063
+ } finally {
2064
+ await requestWithFallbacks({
2065
+ client,
2066
+ methods: ["thread/unsubscribe"],
2067
+ payloads: [{ threadId: params.threadId }, { thread_id: params.threadId }],
2068
+ timeoutMs: settings.requestTimeoutMs,
2069
+ }).catch(() => undefined);
2070
+ }
2071
+ },
2072
+ );
2073
+ }
2074
+
2075
+ async setThreadName(params: {
2076
+ sessionKey?: string;
2077
+ threadId: string;
2078
+ name: string;
2079
+ }): Promise<void> {
2080
+ await withInitializedClient(
2081
+ { settings: this.settings, sessionKey: params.sessionKey },
2082
+ async ({ client, settings }) => {
2083
+ await requestWithFallbacks({
2084
+ client,
2085
+ methods: ["thread/name/set"],
2086
+ payloads: [
2087
+ { threadId: params.threadId, name: params.name },
2088
+ { thread_id: params.threadId, name: params.name },
2089
+ ],
2090
+ timeoutMs: settings.requestTimeoutMs,
2091
+ });
2092
+ },
2093
+ );
2094
+ }
2095
+
2096
+ async setThreadModel(params: {
2097
+ sessionKey?: string;
2098
+ threadId: string;
2099
+ model: string;
2100
+ workspaceDir?: string;
2101
+ }): Promise<ThreadState> {
2102
+ return await withInitializedClient(
2103
+ { settings: this.settings, sessionKey: params.sessionKey },
2104
+ async ({ client, settings }) => {
2105
+ try {
2106
+ const result = await requestWithFallbacks({
2107
+ client,
2108
+ methods: ["thread/resume"],
2109
+ payloads: buildThreadResumePayloads({
2110
+ threadId: params.threadId,
2111
+ model: params.model,
2112
+ cwd: params.workspaceDir,
2113
+ }),
2114
+ timeoutMs: settings.requestTimeoutMs,
2115
+ });
2116
+ return extractThreadState(result);
2117
+ } finally {
2118
+ await requestWithFallbacks({
2119
+ client,
2120
+ methods: ["thread/unsubscribe"],
2121
+ payloads: [{ threadId: params.threadId }, { thread_id: params.threadId }],
2122
+ timeoutMs: settings.requestTimeoutMs,
2123
+ }).catch(() => undefined);
2124
+ }
2125
+ },
2126
+ );
2127
+ }
2128
+
2129
+ async setThreadServiceTier(params: {
2130
+ sessionKey?: string;
2131
+ threadId: string;
2132
+ serviceTier: string | null;
2133
+ }): Promise<ThreadState> {
2134
+ return await withInitializedClient(
2135
+ { settings: this.settings, sessionKey: params.sessionKey },
2136
+ async ({ client, settings }) => {
2137
+ try {
2138
+ const result = await requestWithFallbacks({
2139
+ client,
2140
+ methods: ["thread/resume"],
2141
+ payloads: buildThreadResumePayloads({
2142
+ threadId: params.threadId,
2143
+ serviceTier: params.serviceTier,
2144
+ }),
2145
+ timeoutMs: settings.requestTimeoutMs,
2146
+ });
2147
+ return extractThreadState(result);
2148
+ } finally {
2149
+ await requestWithFallbacks({
2150
+ client,
2151
+ methods: ["thread/unsubscribe"],
2152
+ payloads: [{ threadId: params.threadId }, { thread_id: params.threadId }],
2153
+ timeoutMs: settings.requestTimeoutMs,
2154
+ }).catch(() => undefined);
2155
+ }
2156
+ },
2157
+ );
2158
+ }
2159
+
2160
+ async compactThread(params: {
2161
+ sessionKey?: string;
2162
+ threadId: string;
2163
+ onProgress?: (progress: CompactProgress) => Promise<void> | void;
2164
+ }): Promise<CompactResult> {
2165
+ const client = createJsonRpcClient(this.settings);
2166
+ let latestUsage: ContextUsageSnapshot | undefined;
2167
+ let compactionItemId = "";
2168
+ let compactionCompleted = false;
2169
+ let settleTimer: NodeJS.Timeout | undefined;
2170
+ let resolveCompletion: (() => void) | undefined;
2171
+ let rejectCompletion: ((error: Error) => void) | undefined;
2172
+ const completion = new Promise<void>((resolve, reject) => {
2173
+ resolveCompletion = resolve;
2174
+ rejectCompletion = reject;
2175
+ });
2176
+
2177
+ const settleSoon = () => {
2178
+ if (!resolveCompletion) {
2179
+ return;
2180
+ }
2181
+ if (settleTimer) {
2182
+ clearTimeout(settleTimer);
2183
+ }
2184
+ settleTimer = setTimeout(() => {
2185
+ const resolve = resolveCompletion;
2186
+ resolveCompletion = undefined;
2187
+ rejectCompletion = undefined;
2188
+ resolve?.();
2189
+ }, TRAILING_NOTIFICATION_SETTLE_MS);
2190
+ };
2191
+
2192
+ const fail = (message: string) => {
2193
+ const reject = rejectCompletion;
2194
+ resolveCompletion = undefined;
2195
+ rejectCompletion = undefined;
2196
+ if (settleTimer) {
2197
+ clearTimeout(settleTimer);
2198
+ }
2199
+ reject?.(new Error(message));
2200
+ };
2201
+
2202
+ client.setNotificationHandler(async (method, notificationParams) => {
2203
+ const methodLower = method.trim().toLowerCase();
2204
+ const ids = extractIds(notificationParams);
2205
+ if (ids.threadId && ids.threadId !== params.threadId) {
2206
+ return;
2207
+ }
2208
+ const usage = extractThreadTokenUsageSnapshot(notificationParams);
2209
+ if (usage) {
2210
+ latestUsage = usage;
2211
+ await params.onProgress?.({ phase: "usage", usage });
2212
+ if (compactionCompleted) {
2213
+ settleSoon();
2214
+ }
2215
+ }
2216
+ const progress = extractContextCompactionProgress(methodLower, notificationParams);
2217
+ if (progress) {
2218
+ if (progress.itemId) {
2219
+ compactionItemId = progress.itemId;
2220
+ }
2221
+ if (progress.phase === "completed") {
2222
+ compactionCompleted = true;
2223
+ await params.onProgress?.({
2224
+ phase: "completed",
2225
+ itemId: compactionItemId || progress.itemId,
2226
+ usage: latestUsage,
2227
+ });
2228
+ settleSoon();
2229
+ return;
2230
+ }
2231
+ await params.onProgress?.({
2232
+ phase: "started",
2233
+ itemId: compactionItemId || progress.itemId,
2234
+ usage: latestUsage,
2235
+ });
2236
+ }
2237
+ if (methodLower === "turn/failed") {
2238
+ const turn = asRecord(asRecord(notificationParams)?.turn);
2239
+ const message =
2240
+ pickString(asRecord(turn?.error) ?? {}, ["message"]) ?? "Codex thread compaction failed.";
2241
+ fail(message);
2242
+ }
2243
+ });
2244
+
2245
+ try {
2246
+ await client.connect();
2247
+ await initializeClient({ client, settings: this.settings, sessionKey: params.sessionKey });
2248
+ await requestWithFallbacks({
2249
+ client,
2250
+ methods: ["thread/resume"],
2251
+ payloads: buildThreadResumePayloads({ threadId: params.threadId }),
2252
+ timeoutMs: this.settings.requestTimeoutMs,
2253
+ });
2254
+ await requestWithFallbacks({
2255
+ client,
2256
+ methods: ["thread/compact/start"],
2257
+ payloads: [{ threadId: params.threadId }, { thread_id: params.threadId }],
2258
+ timeoutMs: this.settings.requestTimeoutMs,
2259
+ });
2260
+ await completion;
2261
+ return { itemId: compactionItemId || undefined, usage: latestUsage };
2262
+ } finally {
2263
+ if (settleTimer) {
2264
+ clearTimeout(settleTimer);
2265
+ }
2266
+ await requestWithFallbacks({
2267
+ client,
2268
+ methods: ["thread/unsubscribe"],
2269
+ payloads: [{ threadId: params.threadId }, { thread_id: params.threadId }],
2270
+ timeoutMs: this.settings.requestTimeoutMs,
2271
+ }).catch(() => undefined);
2272
+ await client.close().catch(() => undefined);
2273
+ }
2274
+ }
2275
+
2276
+ async readThreadContext(params: {
2277
+ sessionKey?: string;
2278
+ threadId: string;
2279
+ }): Promise<ThreadReplay> {
2280
+ return await withInitializedClient(
2281
+ { settings: this.settings, sessionKey: params.sessionKey },
2282
+ async ({ client, settings }) => {
2283
+ const result = await requestWithFallbacks({
2284
+ client,
2285
+ methods: ["thread/read"],
2286
+ payloads: [
2287
+ { threadId: params.threadId, includeTurns: true },
2288
+ { thread_id: params.threadId, include_turns: true },
2289
+ ],
2290
+ timeoutMs: settings.requestTimeoutMs,
2291
+ });
2292
+ return extractThreadReplayFromReadResult(result);
2293
+ },
2294
+ );
2295
+ }
2296
+
2297
+ startReview(params: {
2298
+ sessionKey?: string;
2299
+ workspaceDir: string;
2300
+ threadId: string;
2301
+ runId: string;
2302
+ target: ReviewTarget;
2303
+ onPendingInput?: (state: PendingInputState | null) => Promise<void> | void;
2304
+ onInterrupted?: () => Promise<void> | void;
2305
+ }): ActiveCodexRun {
2306
+ const client = createJsonRpcClient(this.settings);
2307
+ let reviewThreadId = params.threadId.trim();
2308
+ let turnId = "";
2309
+ let reviewText = "";
2310
+ let assistantText = "";
2311
+ let awaitingInput = false;
2312
+ let interrupted = false;
2313
+ let completed = false;
2314
+ let notificationQueue = Promise.resolve();
2315
+ const pendingInputCoordinator = createPendingInputCoordinator({
2316
+ inputTimeoutMs: this.settings.inputTimeoutMs,
2317
+ onPendingInput: params.onPendingInput,
2318
+ onActivated: () => {
2319
+ awaitingInput = true;
2320
+ },
2321
+ onCleared: () => {
2322
+ awaitingInput = false;
2323
+ },
2324
+ });
2325
+ let completeTurn: (() => void) | null = null;
2326
+ const completion = new Promise<void>((resolve) => {
2327
+ completeTurn = () => {
2328
+ if (completed) {
2329
+ return;
2330
+ }
2331
+ completed = true;
2332
+ resolve();
2333
+ };
2334
+ });
2335
+
2336
+ const handleResult = (async () => {
2337
+ try {
2338
+ await client.connect();
2339
+ await initializeClient({ client, settings: this.settings, sessionKey: params.sessionKey });
2340
+ await requestWithFallbacks({
2341
+ client,
2342
+ methods: ["thread/resume"],
2343
+ payloads: [{ threadId: reviewThreadId }, { thread_id: reviewThreadId }],
2344
+ timeoutMs: this.settings.requestTimeoutMs,
2345
+ }).catch(() => undefined);
2346
+ const result = await requestWithFallbacks({
2347
+ client,
2348
+ methods: ["review/start"],
2349
+ payloads: [
2350
+ { threadId: reviewThreadId, target: params.target, delivery: "inline" },
2351
+ { thread_id: reviewThreadId, target: params.target, delivery: "inline" },
2352
+ ],
2353
+ timeoutMs: this.settings.requestTimeoutMs,
2354
+ });
2355
+ const resultRecord = asRecord(result);
2356
+ reviewThreadId =
2357
+ pickString(resultRecord ?? {}, ["reviewThreadId", "review_thread_id"]) ?? reviewThreadId;
2358
+ turnId ||= extractIds(result)?.runId ?? "";
2359
+ await completion;
2360
+ if (completed && !interrupted) {
2361
+ await new Promise<void>((resolve) => setTimeout(resolve, TRAILING_NOTIFICATION_SETTLE_MS));
2362
+ await notificationQueue;
2363
+ }
2364
+ const resolvedReviewText = reviewText || assistantText;
2365
+ return {
2366
+ reviewText: resolvedReviewText.trim(),
2367
+ reviewThreadId: reviewThreadId || undefined,
2368
+ turnId: turnId || undefined,
2369
+ aborted: interrupted,
2370
+ } satisfies ReviewResult;
2371
+ } finally {
2372
+ if (reviewThreadId) {
2373
+ await requestWithFallbacks({
2374
+ client,
2375
+ methods: ["thread/unsubscribe"],
2376
+ payloads: [{ threadId: reviewThreadId }, { thread_id: reviewThreadId }],
2377
+ timeoutMs: this.settings.requestTimeoutMs,
2378
+ }).catch(() => undefined);
2379
+ }
2380
+ await client.close().catch(() => undefined);
2381
+ }
2382
+ })();
2383
+
2384
+ client.setNotificationHandler((method, notificationParams) => {
2385
+ const next = notificationQueue.then(async () => {
2386
+ const ids = extractIds(notificationParams);
2387
+ reviewThreadId ||= ids.threadId ?? "";
2388
+ turnId ||= ids.runId ?? "";
2389
+ const methodLower = method.trim().toLowerCase();
2390
+ if (methodLower === "serverrequest/resolved") {
2391
+ await pendingInputCoordinator.clearCurrent();
2392
+ return;
2393
+ }
2394
+ const maybeReviewText = extractReviewTextFromNotification(method, notificationParams);
2395
+ if (maybeReviewText?.trim()) {
2396
+ reviewText = maybeReviewText.trim();
2397
+ }
2398
+ const assistantNotification = extractAssistantNotificationText(methodLower, notificationParams);
2399
+ if (assistantNotification.mode === "snapshot" && assistantNotification.text.trim()) {
2400
+ assistantText = assistantNotification.text.trim();
2401
+ }
2402
+ if (
2403
+ methodLower === "turn/completed" ||
2404
+ methodLower === "turn/failed" ||
2405
+ methodLower === "turn/cancelled"
2406
+ ) {
2407
+ completeTurn?.();
2408
+ }
2409
+ });
2410
+ notificationQueue = next.catch((error: unknown) => {
2411
+ this.logger.debug(`codex review notification handling failed: ${String(error)}`);
2412
+ });
2413
+ return next;
2414
+ });
2415
+
2416
+ client.setRequestHandler(async (method, requestParams) => {
2417
+ const methodLower = method.trim().toLowerCase();
2418
+ if (!isInteractiveServerRequest(method)) {
2419
+ return {};
2420
+ }
2421
+ const ids = extractIds(requestParams);
2422
+ reviewThreadId ||= ids.threadId ?? "";
2423
+ turnId ||= ids.runId ?? "";
2424
+ const options = extractOptionValues(requestParams);
2425
+ const requestId = ids.requestId ?? `${params.runId}-${Date.now().toString(36)}`;
2426
+ const expiresAt = Date.now() + this.settings.inputTimeoutMs;
2427
+ const enrichedRequestParams =
2428
+ methodLower.includes("filechange/requestapproval") && ids.threadId && ids.itemId
2429
+ ? {
2430
+ ...(asRecord(requestParams) ?? {}),
2431
+ filePaths: await readFileChangePathsWithClient({
2432
+ client,
2433
+ settings: this.settings,
2434
+ threadId: ids.threadId,
2435
+ itemId: ids.itemId,
2436
+ workspaceDir: params.workspaceDir,
2437
+ }).catch(() => []),
2438
+ }
2439
+ : requestParams;
2440
+ const state = createPendingInputState({
2441
+ method,
2442
+ requestId,
2443
+ requestParams: enrichedRequestParams,
2444
+ options,
2445
+ expiresAt,
2446
+ });
2447
+ this.logger.debug(
2448
+ `codex review interactive request ${method} (questionnaire=${state.questionnaire ? "yes" : "no"})`,
2449
+ );
2450
+ const pendingEntry = pendingInputCoordinator.enqueue({
2451
+ state,
2452
+ options,
2453
+ actions: state.actions ?? [],
2454
+ methodLower,
2455
+ });
2456
+ const response = await pendingEntry.response;
2457
+ const mappedResponse = mapPendingInputResponse({
2458
+ methodLower,
2459
+ requestParams,
2460
+ response,
2461
+ options,
2462
+ actions: state.actions ?? [],
2463
+ timedOut: pendingEntry.timedOut,
2464
+ });
2465
+ const responseRecord = asRecord(response);
2466
+ const steerText =
2467
+ methodLower.includes("requestapproval") && typeof responseRecord?.steerText === "string"
2468
+ ? responseRecord.steerText.trim()
2469
+ : "";
2470
+ if (steerText && reviewThreadId) {
2471
+ await requestWithFallbacks({
2472
+ client,
2473
+ methods: [...TURN_STEER_METHODS],
2474
+ payloads: [
2475
+ { threadId: reviewThreadId, turnId: turnId || undefined, text: steerText },
2476
+ { thread_id: reviewThreadId, turn_id: turnId || undefined, text: steerText },
2477
+ ],
2478
+ timeoutMs: this.settings.requestTimeoutMs,
2479
+ });
2480
+ }
2481
+ return mappedResponse;
2482
+ });
2483
+
2484
+ return {
2485
+ result: handleResult,
2486
+ queueMessage: async (text) => {
2487
+ const trimmed = text.trim();
2488
+ const pendingInput = pendingInputCoordinator.current();
2489
+ if (!trimmed || !pendingInput) {
2490
+ return false;
2491
+ }
2492
+ const actionSelectionCount =
2493
+ pendingInput.actions.filter((action) => action.kind !== "steer").length ||
2494
+ pendingInput.options.length;
2495
+ const parsed = parseCodexUserInput(trimmed, actionSelectionCount);
2496
+ if (parsed.kind === "option") {
2497
+ await pendingInputCoordinator.settleCurrent({
2498
+ index: parsed.index,
2499
+ option: pendingInput.options[parsed.index] ?? "",
2500
+ });
2501
+ } else if (pendingInput.methodLower.includes("requestapproval")) {
2502
+ await pendingInputCoordinator.settleCurrent({ steerText: parsed.text });
2503
+ } else {
2504
+ await pendingInputCoordinator.settleCurrent({ text: parsed.text });
2505
+ }
2506
+ return true;
2507
+ },
2508
+ submitPendingInput: async (actionIndex) => {
2509
+ const pendingInput = pendingInputCoordinator.current();
2510
+ if (!pendingInput) {
2511
+ return false;
2512
+ }
2513
+ const action = pendingInput.actions[actionIndex];
2514
+ if (!action || action.kind === "steer") {
2515
+ return false;
2516
+ }
2517
+ await pendingInputCoordinator.settleCurrent({
2518
+ index: actionIndex,
2519
+ option: pendingInput.options[actionIndex] ?? "",
2520
+ });
2521
+ return true;
2522
+ },
2523
+ submitPendingInputPayload: async (payload) => {
2524
+ const pendingInput = pendingInputCoordinator.current();
2525
+ if (!pendingInput) {
2526
+ return false;
2527
+ }
2528
+ await pendingInputCoordinator.settleCurrent(payload);
2529
+ return true;
2530
+ },
2531
+ interrupt: async () => {
2532
+ interrupted = true;
2533
+ await params.onInterrupted?.();
2534
+ if (reviewThreadId) {
2535
+ await requestWithFallbacks({
2536
+ client,
2537
+ methods: [...TURN_INTERRUPT_METHODS],
2538
+ payloads: [
2539
+ { threadId: reviewThreadId, turnId: turnId || undefined },
2540
+ { thread_id: reviewThreadId, turn_id: turnId || undefined },
2541
+ ],
2542
+ timeoutMs: this.settings.requestTimeoutMs,
2543
+ }).catch(() => undefined);
2544
+ }
2545
+ completeTurn?.();
2546
+ },
2547
+ isAwaitingInput: () => awaitingInput,
2548
+ getThreadId: () => reviewThreadId || undefined,
2549
+ };
2550
+ }
2551
+
2552
+ startTurn(params: {
2553
+ sessionKey?: string;
2554
+ prompt: string;
2555
+ workspaceDir: string;
2556
+ runId: string;
2557
+ existingThreadId?: string;
2558
+ model?: string;
2559
+ collaborationMode?: CollaborationMode;
2560
+ onPendingInput?: (state: PendingInputState | null) => Promise<void> | void;
2561
+ onInterrupted?: () => Promise<void> | void;
2562
+ }): ActiveCodexRun {
2563
+ const client = createJsonRpcClient(this.settings);
2564
+ let threadId = params.existingThreadId?.trim() || "";
2565
+ let turnId = "";
2566
+ let assistantText = "";
2567
+ let assistantItemId = "";
2568
+ let planExplanation = "";
2569
+ let planSteps: Array<{ step: string; status: "pending" | "inProgress" | "completed" }> = [];
2570
+ const planDraftByItemId = new Map<string, string>();
2571
+ let finalPlanMarkdown = "";
2572
+ let awaitingInput = false;
2573
+ let interrupted = false;
2574
+ let completed = false;
2575
+ let latestContextUsage: ContextUsageSnapshot | undefined;
2576
+ let notificationQueue = Promise.resolve();
2577
+ const pendingInputCoordinator = createPendingInputCoordinator({
2578
+ inputTimeoutMs: this.settings.inputTimeoutMs,
2579
+ onPendingInput: params.onPendingInput,
2580
+ onActivated: () => {
2581
+ awaitingInput = true;
2582
+ assistantText = "";
2583
+ assistantItemId = "";
2584
+ },
2585
+ onCleared: () => {
2586
+ awaitingInput = false;
2587
+ },
2588
+ });
2589
+ let completeTurn: (() => void) | null = null;
2590
+ const completion = new Promise<void>((resolve) => {
2591
+ completeTurn = () => {
2592
+ if (completed) {
2593
+ return;
2594
+ }
2595
+ completed = true;
2596
+ resolve();
2597
+ };
2598
+ });
2599
+
2600
+ client.setNotificationHandler((method, notificationParams) => {
2601
+ const next = notificationQueue.then(async () => {
2602
+ const methodLower = method.trim().toLowerCase();
2603
+ const ids = extractIds(notificationParams);
2604
+ threadId ||= ids.threadId ?? "";
2605
+ turnId ||= ids.runId ?? "";
2606
+ const tokenUsage = extractThreadTokenUsageSnapshot(notificationParams);
2607
+ if (tokenUsage) {
2608
+ latestContextUsage = tokenUsage;
2609
+ }
2610
+ if (methodLower === "serverrequest/resolved") {
2611
+ await pendingInputCoordinator.clearCurrent();
2612
+ return;
2613
+ }
2614
+ if (methodLower === "turn/plan/updated") {
2615
+ const planUpdate = extractTurnPlanUpdate(notificationParams);
2616
+ planExplanation = planUpdate.explanation?.trim() ?? planExplanation;
2617
+ if (planUpdate.steps.length > 0) {
2618
+ planSteps = planUpdate.steps;
2619
+ }
2620
+ }
2621
+ if (methodLower === "item/plan/delta") {
2622
+ const planDelta = extractPlanDeltaNotification(notificationParams);
2623
+ if (planDelta.itemId && planDelta.delta) {
2624
+ const existing = planDraftByItemId.get(planDelta.itemId) ?? "";
2625
+ planDraftByItemId.set(planDelta.itemId, `${existing}${planDelta.delta}`);
2626
+ }
2627
+ return;
2628
+ }
2629
+ if (methodLower === "item/completed") {
2630
+ const completedPlan = extractCompletedPlanText(notificationParams);
2631
+ if (completedPlan.text?.trim()) {
2632
+ finalPlanMarkdown = completedPlan.text.trim();
2633
+ if (completedPlan.itemId) {
2634
+ planDraftByItemId.set(completedPlan.itemId, finalPlanMarkdown);
2635
+ }
2636
+ return;
2637
+ }
2638
+ }
2639
+ const assistantNotification = extractAssistantNotificationText(methodLower, notificationParams);
2640
+ if (
2641
+ assistantNotification.itemId &&
2642
+ assistantItemId &&
2643
+ assistantNotification.itemId !== assistantItemId
2644
+ ) {
2645
+ assistantText = "";
2646
+ }
2647
+ if (assistantNotification.itemId) {
2648
+ assistantItemId = assistantNotification.itemId;
2649
+ }
2650
+ if (assistantNotification.mode === "delta" && assistantNotification.text) {
2651
+ assistantText =
2652
+ assistantText && assistantNotification.text.startsWith(assistantText)
2653
+ ? assistantNotification.text
2654
+ : `${assistantText}${assistantNotification.text}`;
2655
+ } else if (assistantNotification.mode === "snapshot" && assistantNotification.text) {
2656
+ const snapshotText = assistantNotification.text.trim();
2657
+ if (snapshotText) {
2658
+ assistantText = snapshotText;
2659
+ }
2660
+ }
2661
+ if (
2662
+ methodLower === "turn/completed" ||
2663
+ methodLower === "turn/failed" ||
2664
+ methodLower === "turn/cancelled"
2665
+ ) {
2666
+ completeTurn?.();
2667
+ }
2668
+ });
2669
+ notificationQueue = next.catch((error: unknown) => {
2670
+ this.logger.debug(`codex turn notification handling failed: ${String(error)}`);
2671
+ });
2672
+ return next;
2673
+ });
2674
+
2675
+ client.setRequestHandler(async (method, requestParams) => {
2676
+ const methodLower = method.trim().toLowerCase();
2677
+ if (!isInteractiveServerRequest(method)) {
2678
+ return {};
2679
+ }
2680
+ const ids = extractIds(requestParams);
2681
+ threadId ||= ids.threadId ?? "";
2682
+ turnId ||= ids.runId ?? "";
2683
+ const options = extractOptionValues(requestParams);
2684
+ const requestId = ids.requestId ?? `${params.runId}-${Date.now().toString(36)}`;
2685
+ const expiresAt = Date.now() + this.settings.inputTimeoutMs;
2686
+ const enrichedRequestParams =
2687
+ methodLower.includes("filechange/requestapproval") && ids.threadId && ids.itemId
2688
+ ? {
2689
+ ...(asRecord(requestParams) ?? {}),
2690
+ filePaths: await readFileChangePathsWithClient({
2691
+ client,
2692
+ settings: this.settings,
2693
+ threadId: ids.threadId,
2694
+ itemId: ids.itemId,
2695
+ workspaceDir: params.workspaceDir,
2696
+ }).catch(() => []),
2697
+ }
2698
+ : requestParams;
2699
+ const state = createPendingInputState({
2700
+ method,
2701
+ requestId,
2702
+ requestParams: enrichedRequestParams,
2703
+ options,
2704
+ expiresAt,
2705
+ });
2706
+ this.logger.debug(
2707
+ `codex turn interactive request ${method} (questionnaire=${state.questionnaire ? "yes" : "no"})`,
2708
+ );
2709
+ const pendingEntry = pendingInputCoordinator.enqueue({
2710
+ state,
2711
+ options,
2712
+ actions: state.actions ?? [],
2713
+ methodLower,
2714
+ });
2715
+ const response = await pendingEntry.response;
2716
+ const mappedResponse = mapPendingInputResponse({
2717
+ methodLower,
2718
+ requestParams,
2719
+ response,
2720
+ options,
2721
+ actions: state.actions ?? [],
2722
+ timedOut: pendingEntry.timedOut,
2723
+ });
2724
+ const responseRecord = asRecord(response);
2725
+ const steerText =
2726
+ methodLower.includes("requestapproval") && typeof responseRecord?.steerText === "string"
2727
+ ? responseRecord.steerText.trim()
2728
+ : "";
2729
+ if (steerText && threadId) {
2730
+ await requestWithFallbacks({
2731
+ client,
2732
+ methods: [...TURN_STEER_METHODS],
2733
+ payloads: [
2734
+ { threadId, turnId: turnId || undefined, text: steerText },
2735
+ { thread_id: threadId, turn_id: turnId || undefined, text: steerText },
2736
+ ],
2737
+ timeoutMs: this.settings.requestTimeoutMs,
2738
+ });
2739
+ }
2740
+ return mappedResponse;
2741
+ });
2742
+
2743
+ const handleResult = (async () => {
2744
+ try {
2745
+ await client.connect();
2746
+ await initializeClient({ client, settings: this.settings, sessionKey: params.sessionKey });
2747
+ if (!threadId) {
2748
+ const created = await requestWithFallbacks({
2749
+ client,
2750
+ methods: ["thread/new", "thread/start"],
2751
+ payloads: [
2752
+ { cwd: params.workspaceDir, model: params.model },
2753
+ { cwd: params.workspaceDir },
2754
+ {},
2755
+ ],
2756
+ timeoutMs: this.settings.requestTimeoutMs,
2757
+ });
2758
+ threadId = extractIds(created).threadId ?? "";
2759
+ if (!threadId) {
2760
+ throw new Error("Codex App Server did not return a thread id.");
2761
+ }
2762
+ } else {
2763
+ await requestWithFallbacks({
2764
+ client,
2765
+ methods: ["thread/resume"],
2766
+ payloads: [{ threadId }, { thread_id: threadId }],
2767
+ timeoutMs: this.settings.requestTimeoutMs,
2768
+ }).catch(() => undefined);
2769
+ }
2770
+ const started = await requestWithFallbacks({
2771
+ client,
2772
+ methods: ["turn/start"],
2773
+ payloads: buildTurnStartPayloads({
2774
+ threadId,
2775
+ prompt: params.prompt,
2776
+ model: params.model,
2777
+ collaborationMode: params.collaborationMode,
2778
+ }),
2779
+ timeoutMs: this.settings.requestTimeoutMs,
2780
+ });
2781
+ const startedIds = extractIds(started);
2782
+ threadId ||= startedIds.threadId ?? "";
2783
+ turnId ||= startedIds.runId ?? "";
2784
+ await completion;
2785
+ if (completed && !interrupted) {
2786
+ await new Promise<void>((resolve) => setTimeout(resolve, TRAILING_NOTIFICATION_SETTLE_MS));
2787
+ await notificationQueue;
2788
+ }
2789
+ return {
2790
+ threadId,
2791
+ text:
2792
+ finalPlanMarkdown || planDraftByItemId.size > 0 || planSteps.length > 0
2793
+ ? undefined
2794
+ : assistantText || undefined,
2795
+ planArtifact: finalPlanMarkdown
2796
+ ? {
2797
+ explanation: planExplanation || undefined,
2798
+ steps: planSteps,
2799
+ markdown: finalPlanMarkdown,
2800
+ }
2801
+ : undefined,
2802
+ aborted: interrupted,
2803
+ usage: latestContextUsage,
2804
+ } satisfies TurnResult;
2805
+ } finally {
2806
+ if (threadId) {
2807
+ await requestWithFallbacks({
2808
+ client,
2809
+ methods: ["thread/unsubscribe"],
2810
+ payloads: [{ threadId }, { thread_id: threadId }],
2811
+ timeoutMs: this.settings.requestTimeoutMs,
2812
+ }).catch(() => undefined);
2813
+ }
2814
+ await client.close().catch(() => undefined);
2815
+ }
2816
+ })();
2817
+
2818
+ return {
2819
+ result: handleResult,
2820
+ queueMessage: async (text) => {
2821
+ const trimmed = text.trim();
2822
+ if (!trimmed) {
2823
+ return false;
2824
+ }
2825
+ const pendingInput = pendingInputCoordinator.current();
2826
+ if (pendingInput) {
2827
+ const actionSelectionCount =
2828
+ pendingInput.actions.filter((action) => action.kind !== "steer").length ||
2829
+ pendingInput.options.length;
2830
+ const parsed = parseCodexUserInput(trimmed, actionSelectionCount);
2831
+ if (parsed.kind === "option") {
2832
+ const action = pendingInput.actions[parsed.index];
2833
+ if (action?.kind === "steer") {
2834
+ await pendingInputCoordinator.settleCurrent({ steerText: "" });
2835
+ } else {
2836
+ await pendingInputCoordinator.settleCurrent({
2837
+ index: parsed.index,
2838
+ option: pendingInput.options[parsed.index] ?? "",
2839
+ });
2840
+ }
2841
+ } else if (pendingInput.methodLower.includes("requestapproval")) {
2842
+ await pendingInputCoordinator.settleCurrent({ steerText: parsed.text });
2843
+ } else {
2844
+ await pendingInputCoordinator.settleCurrent({ text: parsed.text });
2845
+ }
2846
+ return true;
2847
+ }
2848
+ if (!threadId) {
2849
+ return false;
2850
+ }
2851
+ await requestWithFallbacks({
2852
+ client,
2853
+ methods: [...TURN_STEER_METHODS],
2854
+ payloads: [
2855
+ { threadId, turnId: turnId || undefined, text: trimmed },
2856
+ { thread_id: threadId, turn_id: turnId || undefined, text: trimmed },
2857
+ ],
2858
+ timeoutMs: this.settings.requestTimeoutMs,
2859
+ });
2860
+ return true;
2861
+ },
2862
+ submitPendingInput: async (actionIndex) => {
2863
+ const pendingInput = pendingInputCoordinator.current();
2864
+ if (!pendingInput) {
2865
+ return false;
2866
+ }
2867
+ const action = pendingInput.actions[actionIndex];
2868
+ if (!action || action.kind === "steer") {
2869
+ return false;
2870
+ }
2871
+ await pendingInputCoordinator.settleCurrent({
2872
+ index: actionIndex,
2873
+ option: pendingInput.options[actionIndex] ?? "",
2874
+ });
2875
+ return true;
2876
+ },
2877
+ submitPendingInputPayload: async (payload) => {
2878
+ const pendingInput = pendingInputCoordinator.current();
2879
+ if (!pendingInput) {
2880
+ return false;
2881
+ }
2882
+ await pendingInputCoordinator.settleCurrent(payload);
2883
+ return true;
2884
+ },
2885
+ interrupt: async () => {
2886
+ if (!threadId) {
2887
+ return;
2888
+ }
2889
+ interrupted = true;
2890
+ await params.onInterrupted?.();
2891
+ await requestWithFallbacks({
2892
+ client,
2893
+ methods: [...TURN_INTERRUPT_METHODS],
2894
+ payloads: [
2895
+ { threadId, turnId: turnId || undefined },
2896
+ { thread_id: threadId, turn_id: turnId || undefined },
2897
+ ],
2898
+ timeoutMs: this.settings.requestTimeoutMs,
2899
+ }).catch(() => undefined);
2900
+ completeTurn?.();
2901
+ },
2902
+ isAwaitingInput: () => awaitingInput,
2903
+ getThreadId: () => threadId || undefined,
2904
+ };
2905
+ }
2906
+ }
2907
+
2908
+ export const __testing = {
2909
+ buildTurnStartPayloads,
2910
+ createPendingInputCoordinator,
2911
+ extractFileChangePathsFromReadResult,
2912
+ extractThreadTokenUsageSnapshot,
2913
+ extractRateLimitSummaries,
2914
+ };