@songsid/agend 2.1.6-beta.12 → 2.1.6-beta.14

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.
@@ -7,6 +7,8 @@ export declare class CodexBackend implements CliBackend {
7
7
  private readonly isolatedCodexHome;
8
8
  /** Which subscription this instance runs on, or null for the shared login. */
9
9
  private credentialProfile;
10
+ /** Set only after preTrust wrote and read back this instance's private config. */
11
+ private authorizedTrust;
10
12
  constructor(instanceDir: string);
11
13
  supportsQueuedInput(): boolean;
12
14
  /**
@@ -26,6 +28,8 @@ export declare class CodexBackend implements CliBackend {
26
28
  * (updatePickerDialog) before any delivery path reads this pattern.
27
29
  */
28
30
  getBottomReadyPattern(): RegExp | null;
31
+ /** Observed on real Codex 0.156.0: the live input row precedes its footer. */
32
+ isDeliveryInputReadyPane(pane: string): boolean;
29
33
  /** Live status chrome that must veto the broad prompt/context ready match. */
30
34
  getBusyPattern(): RegExp;
31
35
  /**
@@ -127,9 +131,13 @@ export declare class CodexBackend implements CliBackend {
127
131
  */
128
132
  private cleanSharedConfig;
129
133
  getReadyPattern(): RegExp;
134
+ /** A proxy reply filters chrome per line; whole-pane readiness is separate. */
135
+ isProxyReplyChromeLine(line: string): boolean;
130
136
  getErrorPatterns(): ErrorPattern[];
131
137
  getStartupDialogs(): StartupDialog[];
138
+ private trustHoldDialog;
132
139
  private updatePickerDialog;
140
+ private unknownSelectionHoldDialog;
133
141
  getRuntimeDialogs(): RuntimeDialog[];
134
142
  getInputUnavailableTransients(): InputUnavailableTransient[];
135
143
  getContextUsage(): number | null;
@@ -1,17 +1,32 @@
1
- import { chmodSync, closeSync, cpSync, existsSync, lstatSync, mkdirSync, openSync, readFileSync, readlinkSync, readdirSync, renameSync, rmSync, statSync, symlinkSync, unlinkSync, writeFileSync, } from "node:fs";
1
+ import { chmodSync, closeSync, cpSync, existsSync, lstatSync, mkdirSync, openSync, readFileSync, readlinkSync, readdirSync, realpathSync, renameSync, rmSync, statSync, symlinkSync, unlinkSync, writeFileSync, } from "node:fs";
2
2
  import { homedir } from "node:os";
3
- import { execFile } from "node:child_process";
3
+ import { execFile, execFileSync } from "node:child_process";
4
4
  import { promisify } from "node:util";
5
- import { lastNonBlankRow } from "../pane-input-residue.js";
6
5
  import { basename, dirname, join, resolve } from "node:path";
7
6
  import { probeCliVersion, resolveBinary, shellQuote, validateModel, validateProvider, warnIfModelMismatch } from "./types.js";
8
7
  import { credentialHomeSpec, credentialProfileHome, prepareCredentialProfileHome, resolveCredentialProfile, } from "./credential-profile.js";
9
8
  import { getAgendHome } from "../paths.js";
10
9
  import { appendWithMarker, removeMarker } from "./marker-utils.js";
11
10
  import { t } from "../locale.js";
11
+ import { parse as parseToml } from "smol-toml";
12
12
  const CODEX_PROJECT_DOC_MAX_BYTES = 32_768;
13
13
  const CODEX_MODELS_CACHE_MAX_BYTES = 5 * 1024 * 1024;
14
14
  const SAFE_MODEL_ID_RE = /^[A-Za-z0-9._:/-]+$/;
15
+ /**
16
+ * Whether a pane row is Codex's context footer. Kept when #913 was reverted:
17
+ * #913 introduced it, but #914's pane/ready detection is built on it, and it
18
+ * is pane parsing, not session handling.
19
+ */
20
+ function isCodexContextFooter(row) {
21
+ const context = String.raw `Context\s+\d+%\s+(?:left|used)`;
22
+ const legacy = new RegExp(String.raw `^\s*${context}(?:\s+⚠\s+\d+\s+warnings?\b[^\r\n]*)?(?:\s+·\s+\S[^\r\n]*)?\s*$`, "i");
23
+ if (legacy.test(row))
24
+ return true;
25
+ // A narrow Codex 0.156 pane may truncate the context item after the
26
+ // authoritative first `session-id` item. Keep structural readiness while
27
+ // /ctx honestly reports context unavailable from a truncated percentage.
28
+ return /^\s*[0-9a-f-]{36}\s+·\s+Context\b[^\r\n]*$/i.test(row);
29
+ }
15
30
  const AGEND_MCP_CLEANUP_LOCK = ".agend-mcp-cleanup.lock";
16
31
  const AGEND_MCP_CLEANUP_LOCK_STALE_MS = 30_000;
17
32
  const SQLITE_SIDECAR_RE = /-(?:wal|shm|journal)$/;
@@ -62,6 +77,259 @@ function tomlString(value) {
62
77
  // JSON strings are valid TOML basic strings for the values AgEnD emits.
63
78
  return JSON.stringify(value);
64
79
  }
80
+ /** Codex 0.156 trusts a linked worktree's common repository root, not its CWD. */
81
+ function codexTrustPaths(workingDirectory) {
82
+ const cwd = realpathSync(resolve(workingDirectory));
83
+ try {
84
+ const commonDir = execFileSync("git", ["-C", cwd, "rev-parse", "--path-format=absolute", "--git-common-dir"], {
85
+ encoding: "utf-8", timeout: 2_000, stdio: ["ignore", "pipe", "ignore"],
86
+ }).trim();
87
+ const canonicalCommonDir = realpathSync(commonDir);
88
+ if (basename(canonicalCommonDir) === ".git")
89
+ return { cwd, root: dirname(canonicalCommonDir) };
90
+ // Submodules keep their common dir in another repository's .git/modules.
91
+ const topLevel = execFileSync("git", ["-C", cwd, "rev-parse", "--show-toplevel"], {
92
+ encoding: "utf-8", timeout: 2_000, stdio: ["ignore", "pipe", "ignore"],
93
+ }).trim();
94
+ return { cwd, root: realpathSync(topLevel) };
95
+ }
96
+ catch {
97
+ // A non-Git folder is its own Codex trust root.
98
+ return { cwd, root: cwd };
99
+ }
100
+ }
101
+ function tomlTable(value) {
102
+ return value !== null && typeof value === "object" && !Array.isArray(value) && !(value instanceof Date)
103
+ ? value : null;
104
+ }
105
+ function projectTrustTable(config, root) {
106
+ const projects = tomlTable(tomlTable(config)?.projects);
107
+ return projects ? tomlTable(projects[root]) : null;
108
+ }
109
+ function effectiveProjectTrust(content, root) {
110
+ return projectTrustTable(parseToml(content), root)?.trust_level;
111
+ }
112
+ /** Change only the private project's trust value; parse before and after editing. */
113
+ function setProjectTrusted(content, root) {
114
+ const section = `[projects.${tomlString(root)}]`;
115
+ const parsed = parseToml(content);
116
+ if (projectTrustTable(parsed, root)?.trust_level === "trusted")
117
+ return content;
118
+ const lines = content.split("\n");
119
+ const tableRows = [];
120
+ let multiline = null;
121
+ for (let row = 0; row < lines.length; row++) {
122
+ const line = lines[row];
123
+ if (!multiline && /^\s*\[.*\]\s*(?:#.*)?$/.test(line))
124
+ tableRows.push(row);
125
+ // A multiline config value can quote an entire pane/config. Treat a
126
+ // project-looking row inside it as data, never as effective TOML.
127
+ for (const delimiter of ['"""', "'''"]) {
128
+ if (multiline && multiline !== delimiter)
129
+ continue;
130
+ let count = 0;
131
+ let pos = 0;
132
+ while ((pos = line.indexOf(delimiter, pos)) !== -1) {
133
+ if (delimiter === "'''" || pos === 0 || line[pos - 1] !== "\\")
134
+ count++;
135
+ pos += delimiter.length;
136
+ }
137
+ if (count % 2 === 1)
138
+ multiline = multiline === delimiter ? null : delimiter;
139
+ }
140
+ }
141
+ const headers = tableRows.filter(row => {
142
+ // TOML permits whitespace around dots and quoted/literal table keys.
143
+ // Parse each real header instead of comparing its raw spelling.
144
+ try {
145
+ return projectTrustTable(parseToml(`${lines[row]}\n__agend_trust_probe__ = true\n`), root)?.__agend_trust_probe__ === true;
146
+ }
147
+ catch {
148
+ return false;
149
+ }
150
+ });
151
+ if (headers.length > 1)
152
+ throw new Error("Duplicate Codex trust project table");
153
+ if (headers.length === 0 && projectTrustTable(parsed, root)) {
154
+ throw new Error("Cannot safely edit Codex trust project table");
155
+ }
156
+ let updated;
157
+ if (headers.length === 0) {
158
+ updated = `${content.trimEnd()}\n\n${section}\ntrust_level = "trusted"\n`;
159
+ }
160
+ else {
161
+ const header = headers[0];
162
+ const end = tableRows.find(row => row > header) ?? lines.length;
163
+ const trustRows = [];
164
+ for (let row = header + 1; row < end; row++) {
165
+ if (/^\s*(?:trust_level|"trust_level"|'trust_level')\s*=/.test(lines[row]))
166
+ trustRows.push(row);
167
+ }
168
+ if (trustRows.length > 1)
169
+ throw new Error("Duplicate Codex trust_level key");
170
+ if (trustRows.length === 1)
171
+ lines[trustRows[0]] = 'trust_level = "trusted"';
172
+ else
173
+ lines.splice(header + 1, 0, 'trust_level = "trusted"');
174
+ updated = lines.join("\n");
175
+ }
176
+ // Candidate validation is before atomic write. A spelling the narrow text
177
+ // editor cannot handle must fail closed, never leave Codex with invalid TOML.
178
+ if (effectiveProjectTrust(updated, root) !== "trusted")
179
+ throw new Error("Codex project trust is not effective");
180
+ return updated;
181
+ }
182
+ /** Only the bottom, live Codex 0.156 folder-access screen can own stdin. */
183
+ function codexTrustPromptState(pane) {
184
+ const noPrompt = { active: false, folder: null, root: null, rootNote: "absent", safeChoice: false };
185
+ const rows = pane.replace(/\r/g, "").split("\n");
186
+ let last = rows.length - 1;
187
+ while (last >= 0 && rows[last].trim() === "")
188
+ last--;
189
+ if (last < 0)
190
+ return noPrompt;
191
+ let access = -1;
192
+ for (let index = rows.length - 1; index >= 0; index--) {
193
+ if (/^\s{0,2}Folder access\s*$/.test(rows[index])) {
194
+ access = index;
195
+ break;
196
+ }
197
+ }
198
+ if (access < 0 || last - access > 60)
199
+ return noPrompt;
200
+ const question = rows.findIndex((row, index) => index > access && /^\s{0,2}Trust this folder\?/.test(row));
201
+ if (question < 0 || question >= last)
202
+ return noPrompt;
203
+ // A ready input row or transcript continuation below the question means the
204
+ // trust dialog is history, not the current interactive region.
205
+ if (rows.slice(question + 1, last + 1).some(row => /^\s*[›❯>]\s*(?!\d+\.)\S/.test(row)))
206
+ return noPrompt;
207
+ const noteRows = rows.slice(access + 1, question);
208
+ // The folder must be the first content row after the title. Do not let a
209
+ // later repository-root path stand in for a missing folder path.
210
+ const folderRow = noteRows.find(row => row.trim() !== "");
211
+ const folder = folderRow && /^\s{0,2}\//.test(folderRow) ? folderRow.trim() : null;
212
+ const hasRootNote = noteRows.some(row => /\bNote:|\brepository root\b|Trusting will apply/i.test(row));
213
+ const rootLabel = noteRows.findIndex(row => /\brepository root:/i.test(row));
214
+ const inlineRoot = rootLabel < 0 ? "" : noteRows[rootLabel].split(/\brepository root:/i)[1]?.trim() ?? "";
215
+ const followingRoot = rootLabel < 0 ? "" : noteRows.slice(rootLabel + 1).find(row => row.trim() !== "")?.trim() ?? "";
216
+ const candidateRoot = inlineRoot || followingRoot;
217
+ const root = candidateRoot.startsWith("/") ? candidateRoot : null;
218
+ const rootNote = !hasRootNote ? "absent" : root ? "parsed" : "invalid";
219
+ const choices = rows.slice(question + 1, last + 1).flatMap((row, offset) => {
220
+ const match = row.match(/^\s*([›❯>]?)\s*(\d+)\.\s+(.+?)\s*$/);
221
+ return match ? [{ row: question + 1 + offset, cursor: match[1], number: match[2], text: match[3] }] : [];
222
+ });
223
+ // Unknown option orders, a moved cursor, a third option, or any different
224
+ // footer are held for a human. Never guess where Enter would land.
225
+ const safeChoice = choices.length === 2
226
+ && choices[0].row + 1 === choices[1].row
227
+ && rows.slice(choices[1].row + 1, last).every(row => row.trim() === "")
228
+ && choices[0].cursor === "›" && choices[0].number === "1" && choices[0].text === "Trust and continue"
229
+ && choices[1].cursor === "" && choices[1].number === "2" && choices[1].text === "Quit"
230
+ && /^\s*enter continue\s*·\s*esc quit\s*$/i.test(rows[last]);
231
+ return { active: true, folder, root, rootNote, safeChoice };
232
+ }
233
+ /** Unknown/older trust layouts are still input-blocking, never auto-answered. */
234
+ function codexTrustVariantActive(pane) {
235
+ if (codexTrustPromptState(pane).active)
236
+ return true;
237
+ const rows = pane.replace(/\r/g, "").split("\n");
238
+ let last = rows.length - 1;
239
+ while (last >= 0 && rows[last].trim() === "")
240
+ last--;
241
+ if (last < 0)
242
+ return false;
243
+ const start = Math.max(0, last - 22);
244
+ const folderAccess = rows.findIndex((row, index) => index >= start && /^\s{0,2}Folder access\s*$/.test(row));
245
+ if (folderAccess >= 0) {
246
+ const tail = rows.slice(folderAccess + 1, last + 1);
247
+ if (!tail.some(row => /^\s*[›❯>]\s*(?!\d+\.)\S/.test(row))
248
+ && tail.some(row => /^\s*[›❯>]?\s*\d+\.\s+(?:Open restricted|Trust and continue|Quit)\s*$/i.test(row))
249
+ && /(?:enter|esc|quit|cancel)/i.test(rows[last]))
250
+ return true;
251
+ }
252
+ const question = rows.findIndex((row, index) => index >= start
253
+ && /^\s*(?:Trust this folder\?|Do you trust the files in this folder\?)/i.test(row));
254
+ if (question < 0 || question >= last)
255
+ return false;
256
+ const tail = rows.slice(question + 1, last + 1);
257
+ // A normal input row after a quoted menu makes it history, not live UI.
258
+ if (tail.some(row => /^\s*[›❯>]\s*(?!\d+\.)\S/.test(row)))
259
+ return false;
260
+ return tail.some(row => /^\s*[›❯>]?\s*\d+\.\s+\S/.test(row))
261
+ && (/(?:enter|esc|quit|cancel)/i.test(rows[last])
262
+ || /^\s*[›❯>]?\s*\d+\.\s+\S/.test(rows[last]));
263
+ }
264
+ /** Rate-switch prompts are economic choices, not a fixed keyboard position. */
265
+ function codexRateSwitchVisible(pane) {
266
+ const rows = pane.replace(/\r/g, "").split("\n");
267
+ let last = rows.length - 1;
268
+ while (last >= 0 && rows[last].trim() === "")
269
+ last--;
270
+ let title = -1;
271
+ for (let i = last; i >= Math.max(0, last - 18); i--) {
272
+ if (/^\s*(?:Approaching rate limits|Switch to .{1,120} for lower credit usage\?)\s*$/i.test(rows[i])) {
273
+ title = i;
274
+ break;
275
+ }
276
+ }
277
+ if (title < 0)
278
+ return false;
279
+ const tail = rows.slice(title + 1, last + 1);
280
+ // A copied picker in transcript history is not the currently active menu.
281
+ if (tail.some(row => /^[›>]\s+Ask Codex to do anything\b/.test(row) || isCodexContextFooter(row)))
282
+ return false;
283
+ return tail.some(row => /^\s*[›❯>]?\s*\d+\.\s*(?:Switch to|Keep current model)\b/i.test(row));
284
+ }
285
+ /** Unknown pickers own stdin too; never type or press Enter into one. */
286
+ function codexUnknownSelectionVisible(pane) {
287
+ const rows = pane.replace(/\r/g, "").split("\n");
288
+ let last = rows.length - 1;
289
+ while (last >= 0 && rows[last].trim() === "")
290
+ last--;
291
+ if (last < 0 || !(/\benter\b.*\besc\b/i.test(rows[last])
292
+ || /^\s*Press enter to continue\s*$/i.test(rows[last])))
293
+ return false;
294
+ let selected = -1;
295
+ for (let i = last - 1; i >= Math.max(0, last - 24); i--) {
296
+ if (/^\s*[›❯>]\s+\S/.test(rows[i])) {
297
+ selected = i;
298
+ break;
299
+ }
300
+ }
301
+ if (selected < 0)
302
+ return false;
303
+ if (/^\s*[›❯>]\s+Ask Codex to do anything\b/.test(rows[selected]))
304
+ return false;
305
+ return !rows.slice(selected + 1, last + 1).some(row => /^[›>]\s+Ask Codex to do anything\b/.test(row) || isCodexContextFooter(row));
306
+ }
307
+ /** Only the complete, current Codex installer picker may receive Escape. */
308
+ function codexUpdatePickerVisible(pane) {
309
+ const rows = pane.replace(/\r/g, "").split("\n");
310
+ let last = rows.length - 1;
311
+ while (last >= 0 && rows[last].trim() === "")
312
+ last--;
313
+ if (last < 0 || !/^\s*Press enter to continue\s*$/.test(rows[last]))
314
+ return false;
315
+ for (let first = last - 3; first >= Math.max(0, last - 9); first--) {
316
+ if (!/^\s*[›❯>]\s+1\.\s+Update now\b/.test(rows[first]))
317
+ continue;
318
+ // At 80 columns Codex 0.153 wraps the installer command to the next row.
319
+ // Permit a bounded continuation, but never another option or cursor.
320
+ const second = rows.findIndex((row, index) => index > first && index <= first + 3
321
+ && /^\s*2\.\s+Skip\s*$/.test(row));
322
+ if (second < 0 || !rows.slice(first + 1, second).every(row => /^\s+\S/.test(row) && !/^\s*[›❯>]?\s*\d+\./.test(row))
323
+ || !/^\s*3\.\s+Skip until next version\s*$/.test(rows[second + 1] ?? "")
324
+ || !rows.slice(second + 2, last).every(row => row.trim() === ""))
325
+ continue;
326
+ const intro = rows.slice(Math.max(0, first - 14), first);
327
+ if (intro.some(row => /Update available!/.test(row))
328
+ && intro.some(row => /^\s*Release notes: https:\/\/github\.com\/openai\/codex\/releases\/latest\s*$/.test(row)))
329
+ return true;
330
+ }
331
+ return false;
332
+ }
65
333
  function renderMcpServer(name, entry, instanceName) {
66
334
  const mcpName = `${name}-${instanceName}`.replace(/[^A-Za-z0-9_-]/g, "_");
67
335
  const env = { ...entry.env, AGEND_INSTANCE_NAME: instanceName };
@@ -117,6 +385,8 @@ export class CodexBackend {
117
385
  isolatedCodexHome;
118
386
  /** Which subscription this instance runs on, or null for the shared login. */
119
387
  credentialProfile = null;
388
+ /** Set only after preTrust wrote and read back this instance's private config. */
389
+ authorizedTrust = null;
120
390
  constructor(instanceDir) {
121
391
  this.instanceDir = instanceDir;
122
392
  this.binaryPath = resolveBinary("codex");
@@ -145,6 +415,29 @@ export class CodexBackend {
145
415
  getBottomReadyPattern() {
146
416
  return /^\s*›\s?/;
147
417
  }
418
+ /** Observed on real Codex 0.156.0: the live input row precedes its footer. */
419
+ isDeliveryInputReadyPane(pane) {
420
+ const rows = pane.replace(/\r/g, "").split("\n");
421
+ while (rows.length && !rows[rows.length - 1].trim())
422
+ rows.pop();
423
+ const footer = rows.pop() ?? "";
424
+ // Codex preserves other configured status-line items after the context
425
+ // meter (observed on 0.156.0: "Context 100% left · GPT-6-Astra"). They
426
+ // are footer chrome, not evidence that the input row is unavailable.
427
+ if (!isCodexContextFooter(footer))
428
+ return false;
429
+ // Pasted text may wrap over several continuation rows before the footer.
430
+ // Search only its immediate tail, not a historical transcript prompt.
431
+ for (let i = rows.length - 1; i >= Math.max(0, rows.length - 8); i--) {
432
+ if (/^[>›]\s+\d+\./.test(rows[i]))
433
+ return false;
434
+ if (/^[>›]\s+\S/.test(rows[i]))
435
+ return true;
436
+ if (/^[•■⚠]/.test(rows[i]))
437
+ return false;
438
+ }
439
+ return false;
440
+ }
148
441
  /** Live status chrome that must veto the broad prompt/context ready match. */
149
442
  getBusyPattern() {
150
443
  return /(?:^|\n)•\s+Working\b[^\n]*\besc to interrupt\b/i;
@@ -178,7 +471,7 @@ export class CodexBackend {
178
471
  return false;
179
472
  let footer = -1;
180
473
  for (let i = prompt + 1; i < Math.min(rows.length, prompt + 7); i++) {
181
- if (/^\s*Context\s+\d+%\s+(?:left|used)\s*$/i.test(rows[i])) {
474
+ if (isCodexContextFooter(rows[i])) {
182
475
  footer = i;
183
476
  break;
184
477
  }
@@ -232,13 +525,17 @@ export class CodexBackend {
232
525
  // AgEnD instances are unattended processes: an interactive self-update
233
526
  // picker blocks delivery and must never be enabled by a copied global or
234
527
  // managed config layer. Keep this last so the CLI override is authoritative.
235
- cmd += " -c check_for_update_on_startup=false";
528
+ // Both observed Codex 0.155.0 and 0.156.1 support this launch flag. Pin
529
+ // the initial layout so a user/global fullscreen preference cannot make a
530
+ // header-only pane look ready. A later unknown layout still fails closed.
531
+ cmd += " -c check_for_update_on_startup=false --no-alt-screen";
236
532
  // CODEX_HOME is the only Codex-supported way to isolate the complete base
237
533
  // config. A profile only layers over the shared config and would therefore
238
534
  // still load every globally registered AgEnD MCP server.
239
535
  return `CODEX_HOME=${shellQuote(this.isolatedCodexHome)} ${cmd}`;
240
536
  }
241
537
  writeConfig(config) {
538
+ this.authorizedTrust = null;
242
539
  // Set before the home is prepared: which login this instance gets is a
243
540
  // property of the home, and the home is built here.
244
541
  this.credentialProfile = this.readProfile(config);
@@ -401,16 +698,23 @@ export class CodexBackend {
401
698
  return home;
402
699
  }
403
700
  preTrust(workDir) {
701
+ this.authorizedTrust = null;
702
+ const paths = codexTrustPaths(workDir);
404
703
  const configPath = join(this.isolatedCodexHome, "config.toml");
405
704
  let content = "";
406
705
  try {
407
706
  content = readFileSync(configPath, "utf-8");
408
707
  }
409
708
  catch { }
410
- const section = `[projects."${workDir}"]`;
411
- if (content.includes(section))
412
- return;
413
- atomicWritePrivate(configPath, `${content.trimEnd()}\n\n${section}\ntrust_level = "trusted"\n`);
709
+ const updated = setProjectTrusted(content, paths.root);
710
+ if (updated !== content)
711
+ atomicWritePrivate(configPath, updated);
712
+ // Do not authorize an automatic Enter merely because the write returned:
713
+ // a stale/untrusted section in the effective isolated config must fail shut.
714
+ const onDisk = readFileSync(configPath, "utf-8");
715
+ if (effectiveProjectTrust(onDisk, paths.root) !== "trusted")
716
+ throw new Error("Codex project trust did not persist");
717
+ this.authorizedTrust = paths;
414
718
  }
415
719
  /**
416
720
  * Preserve Codex login/session/cache behavior while isolating config.toml.
@@ -654,11 +958,20 @@ export class CodexBackend {
654
958
  }
655
959
  }
656
960
  getReadyPattern() {
657
- // Startup/header: "OpenAI Codex". Prompt glyph is ">" on older Codex and
658
- // "›" (U+203A) on newer releases. Exclude numbered menu selections so a
659
- // trust/confirmation dialog is not mistaken for an idle input prompt.
660
- // Statusline variants report either "% left" or "% used" while idle.
661
- return /% left|% used|OpenAI Codex|^[>›](?!\s*\d+\.)/m;
961
+ // Header and context text persist in inline scrollback and even while the
962
+ // CLI is loading or a modal owns stdin. Require the live prompt followed
963
+ // by the Context footer at the *end* of the capture. getBusyPattern still
964
+ // vetoes a working turn whose empty composer remains visible. Unknown TUI
965
+ // layouts cannot claim readiness by merely rendering the old header.
966
+ // U+22C6 is Codex's observed cosmetic starfield; it can be drawn in the
967
+ // prompt, between prompt/footer, and below the footer. A drafted composer
968
+ // is also idle once this same bottom footer proves it owns the screen.
969
+ return /(?:^|\n)[>›][ \t⋆]+(?!\d+\.)\S[^\r\n]*\r?\n(?:[ \t⋆]*\r?\n){0,3}[ \t⋆]+(?:[0-9a-f-]{36}[ \t]+·[ \t]+)?Context[ \t]+(?:\d+%[ \t]+(?:left|used)|\d+…|…)[^\r\n]*(?:\r?\n[ \t⋆]*)*$/i;
970
+ }
971
+ /** A proxy reply filters chrome per line; whole-pane readiness is separate. */
972
+ isProxyReplyChromeLine(line) {
973
+ return /^\s*[›>]\s+Ask Codex to do anything\s*$/.test(line)
974
+ || isCodexContextFooter(line);
662
975
  }
663
976
  getErrorPatterns() {
664
977
  return [
@@ -756,12 +1069,40 @@ export class CodexBackend {
756
1069
  ];
757
1070
  }
758
1071
  getStartupDialogs() {
1072
+ const trustHold = this.trustHoldDialog();
759
1073
  return [
760
- { pattern: /Do you trust the files in this folder/i, keys: ["Enter"], description: "Codex trust dialog" },
761
- { pattern: /Yes, continue/i, keys: ["Enter"], description: "Codex 'Yes, continue' confirmation" },
1074
+ {
1075
+ pattern: /^\s*Trust this folder\?/m,
1076
+ keys: ["Enter"],
1077
+ description: "Codex authorized folder trust dialog",
1078
+ blocksDelivery: true,
1079
+ inputBlocked: true,
1080
+ autoResolutionKey: "codex-authorized-folder-trust",
1081
+ isActive: pane => {
1082
+ const state = codexTrustPromptState(pane);
1083
+ const authorized = this.authorizedTrust;
1084
+ return state.active && state.safeChoice && authorized !== null
1085
+ && state.folder === authorized.cwd
1086
+ && (state.rootNote === "absent" ? authorized.root === authorized.cwd
1087
+ : state.rootNote === "parsed" && state.root === authorized.root);
1088
+ },
1089
+ },
1090
+ trustHold,
762
1091
  this.updatePickerDialog(),
1092
+ this.unknownSelectionHoldDialog(),
763
1093
  ];
764
1094
  }
1095
+ trustHoldDialog() {
1096
+ return {
1097
+ pattern: /^\s*(?:Folder access|Trust this folder\?|Do you trust the files in this folder\?)/im,
1098
+ keys: [],
1099
+ description: "Codex folder trust needs human confirmation",
1100
+ holdOnly: true,
1101
+ blocksDelivery: true,
1102
+ inputBlocked: true,
1103
+ isActive: codexTrustVariantActive,
1104
+ };
1105
+ }
765
1106
  updatePickerDialog() {
766
1107
  return {
767
1108
  // Defense in depth for config written by older AgEnD versions or a Codex
@@ -777,26 +1118,41 @@ export class CodexBackend {
777
1118
  // seconds and a delivery can arrive first (hit live on codex-cli 0.153.4,
778
1119
  // which parks on this picker for as long as nobody answers it).
779
1120
  blocksDelivery: true,
780
- // Bottom-anchored, so a transcript that quotes the picker (an agent
781
- // pasting a pane capture, this very change being reviewed) is not
782
- // mistaken for a live one: the real picker owns the bottom of the pane
783
- // and has no input row under it.
784
- isActive: (pane) => {
785
- const last = lastNonBlankRow(pane);
786
- return last != null && /^\s*Press enter to continue\s*$/.test(last);
787
- },
1121
+ // Daemon.dialogMatches uses isActive INSTEAD OF pattern when present.
1122
+ // Check the complete current picker here; a different Enter-only menu
1123
+ // must never receive this automatic Escape key.
1124
+ isActive: codexUpdatePickerVisible,
1125
+ };
1126
+ }
1127
+ unknownSelectionHoldDialog() {
1128
+ return {
1129
+ pattern: /^\s*[›❯>]\s+\S/m,
1130
+ keys: [],
1131
+ description: "Codex interactive selection needs human input",
1132
+ holdOnly: true,
1133
+ blocksDelivery: true,
1134
+ inputBlocked: true,
1135
+ isActive: codexUnknownSelectionVisible,
788
1136
  };
789
1137
  }
790
1138
  getRuntimeDialogs() {
791
1139
  return [
1140
+ this.trustHoldDialog(),
792
1141
  {
793
- // Codex shows a model switch dialog when approaching rate limits.
794
- // Auto-select "Keep current model (never show again)" — option 3.
795
- pattern: /Approaching rate limits[\s\S]*Switch to.*for lower credit/m,
796
- keys: ["Down", "Down", "Enter"],
797
- description: "Codex rate limit model switch dialog",
1142
+ // Codex 0.156 may change the wording/order of this credit-cost choice.
1143
+ // Never navigate it by position: a moved option could switch to a
1144
+ // more expensive model. A live picker holds delivery and notifies a
1145
+ // human; old transcript mentions are excluded by the tail matcher.
1146
+ pattern: /(?:Approaching rate limits|Switch to [^\r\n]{1,120} for lower credit usage\?)/i,
1147
+ keys: [],
1148
+ description: "Codex rate limit model switch dialog needs human choice",
1149
+ holdOnly: true,
1150
+ blocksDelivery: true,
1151
+ inputBlocked: true,
1152
+ isActive: codexRateSwitchVisible,
798
1153
  },
799
1154
  this.updatePickerDialog(),
1155
+ this.unknownSelectionHoldDialog(),
800
1156
  ];
801
1157
  }
802
1158
  getInputUnavailableTransients() {