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.
@@ -0,0 +1,785 @@
1
+ import crypto from "node:crypto";
2
+ import type {
3
+ PendingApprovalDecision,
4
+ PendingInputAction,
5
+ PendingInputState,
6
+ PendingQuestionnaireAnswer,
7
+ PendingQuestionnaireQuestion,
8
+ PendingQuestionnaireState,
9
+ } from "./types.js";
10
+
11
+ const MAX_PENDING_REQUEST_TEXT_CHARS = 1200;
12
+ const MAX_PENDING_PROMPT_TEXT_CHARS = 2200;
13
+
14
+ function asRecord(value: unknown): Record<string, unknown> | undefined {
15
+ return value && typeof value === "object" && !Array.isArray(value)
16
+ ? (value as Record<string, unknown>)
17
+ : undefined;
18
+ }
19
+
20
+ function pickString(
21
+ record: Record<string, unknown> | undefined,
22
+ keys: readonly string[],
23
+ ): string | undefined {
24
+ for (const key of keys) {
25
+ const value = record?.[key];
26
+ if (typeof value !== "string") {
27
+ continue;
28
+ }
29
+ const trimmed = value.trim();
30
+ if (trimmed) {
31
+ return trimmed;
32
+ }
33
+ }
34
+ return undefined;
35
+ }
36
+
37
+ function findFirstStringByKeys(
38
+ value: unknown,
39
+ keys: readonly string[],
40
+ depth = 0,
41
+ ): string | undefined {
42
+ if (depth > 5) {
43
+ return undefined;
44
+ }
45
+ if (Array.isArray(value)) {
46
+ for (const item of value) {
47
+ const match = findFirstStringByKeys(item, keys, depth + 1);
48
+ if (match) {
49
+ return match;
50
+ }
51
+ }
52
+ return undefined;
53
+ }
54
+ const record = asRecord(value);
55
+ if (!record) {
56
+ return undefined;
57
+ }
58
+ const direct = pickString(record, keys);
59
+ if (direct) {
60
+ return direct;
61
+ }
62
+ for (const nested of Object.values(record)) {
63
+ const match = findFirstStringByKeys(nested, keys, depth + 1);
64
+ if (match) {
65
+ return match;
66
+ }
67
+ }
68
+ return undefined;
69
+ }
70
+
71
+ function isFileChangeApprovalMethod(methodLower: string): boolean {
72
+ return methodLower.includes("filechange/requestapproval");
73
+ }
74
+
75
+ function isCommandApprovalMethod(methodLower: string): boolean {
76
+ return methodLower.includes("commandexecution/requestapproval") || methodLower === "turn/requestapproval";
77
+ }
78
+
79
+ function normalizeApprovalDecision(value: string): PendingApprovalDecision | null {
80
+ const normalized = value.trim().toLowerCase();
81
+ switch (normalized) {
82
+ case "accept":
83
+ case "approve":
84
+ case "allow":
85
+ return "accept";
86
+ case "acceptwithexecpolicyamendment":
87
+ case "acceptforsession":
88
+ case "approveforsession":
89
+ case "allowforsession":
90
+ return "acceptForSession";
91
+ case "decline":
92
+ case "deny":
93
+ case "reject":
94
+ return "decline";
95
+ case "cancel":
96
+ case "abort":
97
+ case "stop":
98
+ return "cancel";
99
+ default:
100
+ return null;
101
+ }
102
+ }
103
+
104
+ function humanizeApprovalDecision(
105
+ decision: PendingApprovalDecision,
106
+ sessionPrefix?: string,
107
+ ): string {
108
+ switch (decision) {
109
+ case "accept":
110
+ return "Approve Once";
111
+ case "acceptForSession":
112
+ return sessionPrefix ? `Approve for Session (${sessionPrefix})` : "Approve for Session";
113
+ case "decline":
114
+ return "Decline";
115
+ case "cancel":
116
+ return "Cancel";
117
+ }
118
+ }
119
+
120
+ function extractSessionPrefix(value: unknown): string | undefined {
121
+ const record = asRecord(value);
122
+ return (
123
+ findFirstStringByKeys(record?.proposedExecpolicyAmendment, [
124
+ "prefix",
125
+ "commandPrefix",
126
+ "prefixToApprove",
127
+ "allowedPrefix",
128
+ "command_prefix",
129
+ ]) ??
130
+ findFirstStringByKeys(record?.sessionApproval, [
131
+ "prefix",
132
+ "commandPrefix",
133
+ "prefixToApprove",
134
+ "allowedPrefix",
135
+ "command_prefix",
136
+ ]) ??
137
+ findFirstStringByKeys(record?.execPolicyAmendment, [
138
+ "prefix",
139
+ "commandPrefix",
140
+ "prefixToApprove",
141
+ "allowedPrefix",
142
+ "command_prefix",
143
+ ])
144
+ );
145
+ }
146
+
147
+ function buildApprovalActionsFromDecisions(value: unknown): PendingInputAction[] {
148
+ const record = asRecord(value);
149
+ const rawDecisions = record?.availableDecisions ?? record?.decisions;
150
+ if (!Array.isArray(rawDecisions)) {
151
+ return [];
152
+ }
153
+ const actions: PendingInputAction[] = [];
154
+ for (const entry of rawDecisions) {
155
+ if (typeof entry === "string") {
156
+ const decision = normalizeApprovalDecision(entry);
157
+ if (!decision) {
158
+ continue;
159
+ }
160
+ actions.push({
161
+ kind: "approval",
162
+ decision,
163
+ responseDecision: entry,
164
+ label: humanizeApprovalDecision(decision),
165
+ });
166
+ continue;
167
+ }
168
+ const decisionRecord = asRecord(entry);
169
+ const decisionValue =
170
+ pickString(decisionRecord, ["decision", "value", "name", "id", "action"]) ?? "";
171
+ const decision = normalizeApprovalDecision(decisionValue);
172
+ if (!decision) {
173
+ continue;
174
+ }
175
+ const sessionPrefix =
176
+ decision === "acceptForSession" ? extractSessionPrefix(decisionRecord) : undefined;
177
+ const proposedExecpolicyAmendment =
178
+ decision === "acceptForSession"
179
+ ? (asRecord(decisionRecord?.proposedExecpolicyAmendment) ??
180
+ asRecord(decisionRecord?.execPolicyAmendment) ??
181
+ undefined)
182
+ : undefined;
183
+ actions.push({
184
+ kind: "approval",
185
+ decision,
186
+ responseDecision: decisionValue || decision,
187
+ ...(proposedExecpolicyAmendment ? { proposedExecpolicyAmendment } : {}),
188
+ ...(sessionPrefix ? { sessionPrefix } : {}),
189
+ label:
190
+ pickString(decisionRecord, ["label", "title", "text"]) ??
191
+ humanizeApprovalDecision(decision, sessionPrefix),
192
+ });
193
+ }
194
+ return actions;
195
+ }
196
+
197
+ function resolveApprovalDecisionFromText(text: string): PendingApprovalDecision | null {
198
+ const normalized = text.trim().toLowerCase();
199
+ if (!normalized) {
200
+ return null;
201
+ }
202
+ if (normalized.includes("session")) {
203
+ return "acceptForSession";
204
+ }
205
+ if (/cancel|abort|stop/.test(normalized)) {
206
+ return "cancel";
207
+ }
208
+ if (/deny|decline|reject|block|no/.test(normalized)) {
209
+ return "decline";
210
+ }
211
+ if (/approve|allow|accept|yes/.test(normalized)) {
212
+ return "accept";
213
+ }
214
+ return null;
215
+ }
216
+
217
+ function buildApprovalActionsFromOptions(options: string[]): PendingInputAction[] {
218
+ const seen = new Set<PendingApprovalDecision>();
219
+ const actions: PendingInputAction[] = [];
220
+ for (const option of options) {
221
+ const decision = resolveApprovalDecisionFromText(option);
222
+ if (!decision || seen.has(decision)) {
223
+ continue;
224
+ }
225
+ seen.add(decision);
226
+ actions.push({
227
+ kind: "approval",
228
+ decision,
229
+ responseDecision: decision,
230
+ label: option.trim() || humanizeApprovalDecision(decision),
231
+ });
232
+ }
233
+ return actions;
234
+ }
235
+
236
+ function buildApprovalActionsFromMethod(
237
+ methodLower: string,
238
+ requestParams: unknown,
239
+ ): PendingInputAction[] {
240
+ if (isFileChangeApprovalMethod(methodLower)) {
241
+ return [
242
+ {
243
+ kind: "approval",
244
+ decision: "accept",
245
+ responseDecision: "accept",
246
+ label: "Approve File Changes",
247
+ },
248
+ {
249
+ kind: "approval",
250
+ decision: "decline",
251
+ responseDecision: "decline",
252
+ label: "Decline",
253
+ },
254
+ ];
255
+ }
256
+ if (!isCommandApprovalMethod(methodLower)) {
257
+ return [];
258
+ }
259
+ const sessionPrefix = extractSessionPrefix(requestParams);
260
+ const actions: PendingInputAction[] = [
261
+ {
262
+ kind: "approval",
263
+ decision: "accept",
264
+ responseDecision: "accept",
265
+ label: "Approve Once",
266
+ },
267
+ ];
268
+ if (sessionPrefix) {
269
+ actions.push({
270
+ kind: "approval",
271
+ decision: "acceptForSession",
272
+ responseDecision: "acceptForSession",
273
+ sessionPrefix,
274
+ label: humanizeApprovalDecision("acceptForSession", sessionPrefix),
275
+ });
276
+ }
277
+ actions.push(
278
+ {
279
+ kind: "approval",
280
+ decision: "decline",
281
+ responseDecision: "decline",
282
+ label: "Decline",
283
+ },
284
+ {
285
+ kind: "approval",
286
+ decision: "cancel",
287
+ responseDecision: "cancel",
288
+ label: "Cancel",
289
+ },
290
+ );
291
+ return actions;
292
+ }
293
+
294
+ function extractFilePaths(value: unknown): string[] {
295
+ const record = asRecord(value);
296
+ if (!record) {
297
+ return [];
298
+ }
299
+ const seen = new Set<string>();
300
+ const out: string[] = [];
301
+ const pushPath = (pathValue: unknown) => {
302
+ if (typeof pathValue !== "string") {
303
+ return;
304
+ }
305
+ const trimmed = pathValue.trim();
306
+ if (!trimmed || seen.has(trimmed)) {
307
+ return;
308
+ }
309
+ seen.add(trimmed);
310
+ out.push(trimmed);
311
+ };
312
+ const filePaths = Array.isArray(record.filePaths)
313
+ ? record.filePaths
314
+ : Array.isArray(record.file_paths)
315
+ ? record.file_paths
316
+ : [];
317
+ filePaths.forEach((entry) => pushPath(entry));
318
+ const changes = Array.isArray(record.changes) ? record.changes : [];
319
+ changes.forEach((entry) => pushPath(asRecord(entry)?.path));
320
+ return out;
321
+ }
322
+
323
+ export function buildPendingUserInputActions(params: {
324
+ method?: string;
325
+ requestParams?: unknown;
326
+ options?: string[];
327
+ }): PendingInputAction[] {
328
+ const methodLower = params.method?.trim().toLowerCase() ?? "";
329
+ const options = params.options?.map((option) => option.trim()).filter(Boolean) ?? [];
330
+ if (methodLower.includes("requestapproval")) {
331
+ const approvalActions = buildApprovalActionsFromDecisions(params.requestParams);
332
+ const resolvedApprovalActions =
333
+ approvalActions.length > 0
334
+ ? approvalActions
335
+ : buildApprovalActionsFromOptions(options).length > 0
336
+ ? buildApprovalActionsFromOptions(options)
337
+ : buildApprovalActionsFromMethod(methodLower, params.requestParams);
338
+ return [...resolvedApprovalActions, { kind: "steer", label: "Tell Codex What To Do" }];
339
+ }
340
+ return options.map((option) => ({
341
+ kind: "option",
342
+ label: option,
343
+ value: option,
344
+ }));
345
+ }
346
+
347
+ function dedupeJoinedText(chunks: string[]): string {
348
+ const seen = new Set<string>();
349
+ const out: string[] = [];
350
+ for (const chunk of chunks.map((value) => value.trim()).filter(Boolean)) {
351
+ if (seen.has(chunk)) {
352
+ continue;
353
+ }
354
+ seen.add(chunk);
355
+ out.push(chunk);
356
+ }
357
+ return out.join("\n\n").trim();
358
+ }
359
+
360
+ function truncateWithNotice(text: string, maxChars: number, notice: string): string {
361
+ const trimmed = text.trim();
362
+ if (trimmed.length <= maxChars) {
363
+ return trimmed;
364
+ }
365
+ return `${trimmed.slice(0, Math.max(1, maxChars)).trimEnd()}\n\n${notice}`;
366
+ }
367
+
368
+ function parseQuestionnaireOption(line: string): { key: string; label: string } | null {
369
+ const match = line.trim().match(/^[•*-]?\s*([A-Z])[\.\)]?\s+(.+)$/);
370
+ if (!match?.[1] || !match[2]) {
371
+ return null;
372
+ }
373
+ return {
374
+ key: match[1],
375
+ label: match[2].trim(),
376
+ };
377
+ }
378
+
379
+ function extractQuestionnaireFromStructuredRequest(
380
+ value: unknown,
381
+ ): PendingQuestionnaireState | undefined {
382
+ const record = asRecord(value);
383
+ const rawQuestions = Array.isArray(record?.questions) ? record.questions : [];
384
+ if (rawQuestions.length === 0) {
385
+ return undefined;
386
+ }
387
+ const questions: PendingQuestionnaireQuestion[] = rawQuestions
388
+ .map((entry, index) => {
389
+ const question = asRecord(entry);
390
+ if (!question) {
391
+ return null;
392
+ }
393
+ const rawOptions = Array.isArray(question.options) ? question.options : [];
394
+ const options = rawOptions
395
+ .map((option, optionIndex) => {
396
+ const optionRecord = asRecord(option);
397
+ if (!optionRecord) {
398
+ return null;
399
+ }
400
+ const label = pickString(optionRecord, ["label", "title", "text"]);
401
+ if (!label) {
402
+ return null;
403
+ }
404
+ return {
405
+ key: String.fromCharCode(65 + optionIndex),
406
+ label,
407
+ description: pickString(optionRecord, ["description", "details", "summary"]),
408
+ recommended: /\(recommended\)/i.test(label),
409
+ };
410
+ })
411
+ .filter(Boolean) as PendingQuestionnaireQuestion["options"];
412
+ if (options.length === 0) {
413
+ return null;
414
+ }
415
+ const header = pickString(question, ["header"]);
416
+ const prompt = pickString(question, ["question"]) ?? header ?? `Question ${index + 1}`;
417
+ return {
418
+ index,
419
+ id: pickString(question, ["id"]) ?? `q${index + 1}`,
420
+ header,
421
+ prompt,
422
+ options,
423
+ guidance: [],
424
+ allowFreeform: question.isOther === true || question.is_other === true,
425
+ };
426
+ })
427
+ .filter(Boolean) as PendingQuestionnaireQuestion[];
428
+ if (questions.length === 0) {
429
+ return undefined;
430
+ }
431
+ return {
432
+ questions,
433
+ currentIndex: 0,
434
+ answers: questions.map(() => null),
435
+ responseMode: "structured",
436
+ };
437
+ }
438
+
439
+ export function parsePendingQuestionnaire(text: string): PendingQuestionnaireState | undefined {
440
+ const normalized = text.replace(/\r\n/g, "\n").trim();
441
+ if (!normalized) {
442
+ return undefined;
443
+ }
444
+ const starts = [...normalized.matchAll(/(?:^|\n)(\d+)\.\s+/g)].map((match) => match.index ?? 0);
445
+ if (starts.length < 2) {
446
+ return undefined;
447
+ }
448
+ const questions: PendingQuestionnaireQuestion[] = [];
449
+ for (let index = 0; index < starts.length; index += 1) {
450
+ const start = starts[index] ?? 0;
451
+ const end = starts[index + 1] ?? normalized.length;
452
+ const block = normalized
453
+ .slice(start, end)
454
+ .trim()
455
+ .replace(/^\d+\.\s+/, "");
456
+ const lines = block.split("\n");
457
+ const prompt = lines.shift()?.trim() ?? "";
458
+ if (!prompt) {
459
+ continue;
460
+ }
461
+ const options: Array<{ key: string; label: string }> = [];
462
+ const guidance: string[] = [];
463
+ let inGuidance = false;
464
+ for (const rawLine of lines) {
465
+ const line = rawLine.trim();
466
+ if (!line) {
467
+ continue;
468
+ }
469
+ if (/^guidance:?$/i.test(line)) {
470
+ inGuidance = true;
471
+ continue;
472
+ }
473
+ const option = parseQuestionnaireOption(line);
474
+ if (option && !inGuidance) {
475
+ options.push(option);
476
+ continue;
477
+ }
478
+ if (inGuidance) {
479
+ guidance.push(line.replace(/^[•*-]\s*/, "").trim());
480
+ }
481
+ }
482
+ if (options.length === 0) {
483
+ continue;
484
+ }
485
+ questions.push({
486
+ index: questions.length,
487
+ id: `q${questions.length + 1}`,
488
+ prompt,
489
+ options,
490
+ guidance,
491
+ });
492
+ }
493
+ if (questions.length < 2) {
494
+ return undefined;
495
+ }
496
+ return {
497
+ questions,
498
+ currentIndex: 0,
499
+ answers: questions.map(() => null),
500
+ responseMode: "compact",
501
+ };
502
+ }
503
+
504
+ export function formatPendingQuestionnairePrompt(
505
+ questionnaire: PendingQuestionnaireState,
506
+ ): string {
507
+ const question = questionnaire.questions[questionnaire.currentIndex];
508
+ if (!question) {
509
+ return "Codex needs input.";
510
+ }
511
+ const heading =
512
+ question.header && question.prompt && question.header !== question.prompt
513
+ ? `${question.header}: ${question.prompt}`
514
+ : (question.header ?? question.prompt);
515
+ const lines = [
516
+ `Codex plan question ${questionnaire.currentIndex + 1} of ${questionnaire.questions.length}`,
517
+ "",
518
+ heading,
519
+ "",
520
+ ];
521
+ for (const option of question.options) {
522
+ lines.push(`${option.key}. ${option.label}`);
523
+ if (option.description) {
524
+ lines.push(` ${option.description}`);
525
+ }
526
+ }
527
+ if (question.guidance.length > 0) {
528
+ lines.push("", "Guidance:");
529
+ for (const item of question.guidance) {
530
+ lines.push(`- ${item}`);
531
+ }
532
+ }
533
+ if (question.allowFreeform) {
534
+ lines.push("", "Other: You can reply with free text.");
535
+ }
536
+ const currentAnswer = questionnaire.answers[questionnaire.currentIndex];
537
+ if (currentAnswer) {
538
+ lines.push(
539
+ "",
540
+ `Current answer: ${
541
+ currentAnswer.kind === "option"
542
+ ? `${currentAnswer.optionKey}. ${currentAnswer.optionLabel}`
543
+ : currentAnswer.text
544
+ }`,
545
+ );
546
+ } else if (questionnaire.awaitingFreeform) {
547
+ lines.push("", "Current answer: waiting for your free-form reply");
548
+ }
549
+ return lines.join("\n");
550
+ }
551
+
552
+ export function renderPendingQuestionnaireAnswer(answer: PendingQuestionnaireAnswer | null): string {
553
+ if (!answer) {
554
+ return "";
555
+ }
556
+ return answer.kind === "option" ? answer.optionLabel.trim() : answer.text.trim();
557
+ }
558
+
559
+ export function buildPendingQuestionnaireResponse(
560
+ questionnaire: PendingQuestionnaireState,
561
+ ): { answers: Record<string, { answers: string[] }> } | string {
562
+ if (questionnaire.responseMode === "compact") {
563
+ return questionnaire.questions
564
+ .map((question, index) => {
565
+ const answer = questionnaire.answers[index];
566
+ if (!answer) {
567
+ return "";
568
+ }
569
+ return answer.kind === "option"
570
+ ? `${question.index + 1}${answer.optionKey}`
571
+ : `${question.index + 1}: ${answer.text.trim()}`;
572
+ })
573
+ .filter(Boolean)
574
+ .join(" ");
575
+ }
576
+ return {
577
+ answers: Object.fromEntries(
578
+ questionnaire.questions.map((question, index) => {
579
+ const answer = questionnaire.answers[index];
580
+ const rendered = renderPendingQuestionnaireAnswer(answer);
581
+ return [question.id, { answers: rendered ? [rendered] : [] }];
582
+ }),
583
+ ),
584
+ };
585
+ }
586
+
587
+ export function questionnaireIsComplete(questionnaire: PendingQuestionnaireState): boolean {
588
+ return questionnaire.answers.every(
589
+ (answer) =>
590
+ answer != null &&
591
+ (answer.kind === "option" || (answer.kind === "text" && answer.text.trim().length > 0)),
592
+ );
593
+ }
594
+
595
+ export function questionnaireCurrentQuestionHasAnswer(
596
+ questionnaire: PendingQuestionnaireState,
597
+ ): boolean {
598
+ const answer = questionnaire.answers[questionnaire.currentIndex];
599
+ return (
600
+ answer != null &&
601
+ (answer.kind === "option" || (answer.kind === "text" && answer.text.trim().length > 0))
602
+ );
603
+ }
604
+
605
+ function collectText(value: unknown): string[] {
606
+ if (typeof value === "string") {
607
+ const trimmed = value.trim();
608
+ return trimmed ? [trimmed] : [];
609
+ }
610
+ if (Array.isArray(value)) {
611
+ return value.flatMap((entry) => collectText(entry));
612
+ }
613
+ const record = asRecord(value);
614
+ if (!record) {
615
+ return [];
616
+ }
617
+ const directKeys = [
618
+ "text",
619
+ "delta",
620
+ "message",
621
+ "prompt",
622
+ "question",
623
+ "summary",
624
+ "title",
625
+ "content",
626
+ "description",
627
+ "reason",
628
+ ];
629
+ const out = directKeys.flatMap((key) => collectText(record[key]));
630
+ for (const nestedKey of ["item", "turn", "thread", "response", "result", "data", "questions"]) {
631
+ out.push(...collectText(record[nestedKey]));
632
+ }
633
+ return out;
634
+ }
635
+
636
+ function buildMarkdownCodeBlock(text: string, language = ""): string {
637
+ const normalized = text.replace(/\r\n/g, "\n").trim();
638
+ if (!normalized) {
639
+ return "";
640
+ }
641
+ const fenceMatches = [...normalized.matchAll(/`{3,}/g)];
642
+ const longestFence = fenceMatches.reduce((max, match) => Math.max(max, match[0].length), 2);
643
+ const fence = "`".repeat(longestFence + 1);
644
+ const languageTag = language.trim();
645
+ return `${fence}${languageTag}\n${normalized}\n${fence}`;
646
+ }
647
+
648
+ export function buildPendingPromptText(params: {
649
+ method: string;
650
+ requestId: string;
651
+ options: string[];
652
+ actions: PendingInputAction[];
653
+ expiresAt: number;
654
+ requestParams: unknown;
655
+ }): string {
656
+ const methodLower = params.method.trim().toLowerCase();
657
+ const lines = [
658
+ /requestapproval/i.test(params.method)
659
+ ? isFileChangeApprovalMethod(methodLower)
660
+ ? `Codex file change approval requested (${params.requestId})`
661
+ : isCommandApprovalMethod(methodLower)
662
+ ? `Codex command approval requested (${params.requestId})`
663
+ : `Codex approval requested (${params.requestId})`
664
+ : `Codex input requested (${params.requestId})`,
665
+ ];
666
+ const requestText = dedupeJoinedText(collectText(params.requestParams));
667
+ if (requestText) {
668
+ lines.push(
669
+ truncateWithNotice(
670
+ requestText,
671
+ MAX_PENDING_REQUEST_TEXT_CHARS,
672
+ "[Request details truncated. Use steer text if you want to redirect Codex.]",
673
+ ),
674
+ );
675
+ }
676
+ const command =
677
+ findFirstStringByKeys(params.requestParams, [
678
+ "command",
679
+ "cmd",
680
+ "displayCommand",
681
+ "rawCommand",
682
+ "shellCommand",
683
+ ]) ?? "";
684
+ if (command) {
685
+ lines.push("", "Command:", "", buildMarkdownCodeBlock(command, "sh"));
686
+ }
687
+ const grantRoot = findFirstStringByKeys(params.requestParams, ["grantRoot", "grant_root"]);
688
+ if (grantRoot) {
689
+ lines.push("", `Requested writable root: \`${grantRoot}\``);
690
+ }
691
+ if (isFileChangeApprovalMethod(methodLower)) {
692
+ const filePaths = extractFilePaths(params.requestParams);
693
+ if (filePaths.length > 0) {
694
+ lines.push("", "Files:");
695
+ for (const filePath of filePaths.slice(0, 12)) {
696
+ lines.push(`- \`${filePath}\``);
697
+ }
698
+ if (filePaths.length > 12) {
699
+ lines.push(`- ...and ${filePaths.length - 12} more`);
700
+ }
701
+ }
702
+ }
703
+ if (params.actions.length > 0) {
704
+ lines.push("", "Choices:");
705
+ params.actions
706
+ .filter((action) => action.kind !== "steer")
707
+ .forEach((action, index) => {
708
+ lines.push(`${index + 1}. ${action.label}`);
709
+ });
710
+ lines.push("", 'Reply with "1", "2", "option 1", etc., or use a button.');
711
+ if (/requestapproval/i.test(params.method)) {
712
+ lines.push("You can also reply with free text to tell Codex what to do instead.");
713
+ }
714
+ } else if (params.options.length > 0) {
715
+ lines.push("", "Options:");
716
+ params.options.forEach((option, index) => {
717
+ lines.push(`${index + 1}. ${option}`);
718
+ });
719
+ } else {
720
+ lines.push("Reply with a free-form response.");
721
+ }
722
+ const seconds = Math.max(1, Math.round((params.expiresAt - Date.now()) / 1_000));
723
+ lines.push(`Expires in: ${seconds}s`);
724
+ return truncateWithNotice(
725
+ lines.join("\n"),
726
+ MAX_PENDING_PROMPT_TEXT_CHARS,
727
+ "[Prompt truncated for chat delivery. Use the buttons or reply with steer text.]",
728
+ );
729
+ }
730
+
731
+ export function createPendingInputState(params: {
732
+ method: string;
733
+ requestId: string;
734
+ requestParams: unknown;
735
+ options: string[];
736
+ expiresAt: number;
737
+ }): PendingInputState {
738
+ const actions = buildPendingUserInputActions({
739
+ method: params.method,
740
+ requestParams: params.requestParams,
741
+ options: params.options,
742
+ });
743
+ const questionnaire =
744
+ extractQuestionnaireFromStructuredRequest(params.requestParams) ??
745
+ parsePendingQuestionnaire(dedupeJoinedText(collectText(params.requestParams)));
746
+ return {
747
+ requestId: params.requestId,
748
+ options: params.options,
749
+ actions,
750
+ expiresAt: params.expiresAt,
751
+ questionnaire,
752
+ promptText: buildPendingPromptText({
753
+ method: params.method,
754
+ requestId: params.requestId,
755
+ options: params.options,
756
+ actions,
757
+ expiresAt: params.expiresAt,
758
+ requestParams: params.requestParams,
759
+ }),
760
+ method: params.method,
761
+ };
762
+ }
763
+
764
+ export function parseCodexUserInput(
765
+ text: string,
766
+ optionsCount: number,
767
+ ): { kind: "option"; index: number } | { kind: "text"; text: string } {
768
+ const normalized = text.trim();
769
+ if (!normalized) {
770
+ return { kind: "text", text: "" };
771
+ }
772
+ const match = normalized.match(/^\s*(?:option\s*)?([1-9]\d*)\s*$/i);
773
+ if (!match) {
774
+ return { kind: "text", text: normalized };
775
+ }
776
+ const oneBased = Number.parseInt(match[1] ?? "", 10);
777
+ if (Number.isInteger(oneBased) && oneBased >= 1 && oneBased <= optionsCount) {
778
+ return { kind: "option", index: oneBased - 1 };
779
+ }
780
+ return { kind: "text", text: normalized };
781
+ }
782
+
783
+ export function requestToken(requestId: string): string {
784
+ return crypto.createHash("sha1").update(requestId).digest("base64url").slice(0, 10);
785
+ }