@songsid/agend 2.1.6-beta.11 → 2.1.6-beta.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/README.md +22 -0
  2. package/dist/backend/codex-session.d.ts +45 -0
  3. package/dist/backend/codex-session.js +202 -0
  4. package/dist/backend/codex-session.js.map +1 -0
  5. package/dist/backend/codex.d.ts +48 -12
  6. package/dist/backend/codex.js +700 -65
  7. package/dist/backend/codex.js.map +1 -1
  8. package/dist/backend/kiro.d.ts +9 -0
  9. package/dist/backend/kiro.js +129 -0
  10. package/dist/backend/kiro.js.map +1 -1
  11. package/dist/backend/muse.d.ts +9 -4
  12. package/dist/backend/muse.js +89 -34
  13. package/dist/backend/muse.js.map +1 -1
  14. package/dist/backend/types.d.ts +25 -1
  15. package/dist/backend/types.js.map +1 -1
  16. package/dist/cli.js +23 -1
  17. package/dist/cli.js.map +1 -1
  18. package/dist/daemon.d.ts +41 -7
  19. package/dist/daemon.js +546 -91
  20. package/dist/daemon.js.map +1 -1
  21. package/dist/fleet-manager.d.ts +11 -1
  22. package/dist/fleet-manager.js +57 -3
  23. package/dist/fleet-manager.js.map +1 -1
  24. package/dist/instance-lifecycle.d.ts +7 -0
  25. package/dist/instance-lifecycle.js +126 -2
  26. package/dist/instance-lifecycle.js.map +1 -1
  27. package/dist/locale.js +6 -0
  28. package/dist/locale.js.map +1 -1
  29. package/dist/muse-usage-relay.js +14 -2
  30. package/dist/muse-usage-relay.js.map +1 -1
  31. package/dist/tmux-manager.d.ts +2 -0
  32. package/dist/tmux-manager.js +20 -0
  33. package/dist/tmux-manager.js.map +1 -1
  34. package/dist/usage/providers.js +10 -2
  35. package/dist/usage/providers.js.map +1 -1
  36. package/dist/usage/usage-api.d.ts +0 -12
  37. package/dist/usage/usage-api.js +24 -3
  38. package/dist/usage/usage-api.js.map +1 -1
  39. package/package.json +2 -1
@@ -1,20 +1,31 @@
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
+ import { CODEX_SESSION_ID, CodexResumeIdentityError, codexCurrentSessionFromPane, codexRolloutForId, codexSessionsForPane, codexSessionOwners, readCodexRolloutMeta } from "./codex-session.js";
12
13
  const CODEX_PROJECT_DOC_MAX_BYTES = 32_768;
13
14
  const CODEX_MODELS_CACHE_MAX_BYTES = 5 * 1024 * 1024;
14
15
  const SAFE_MODEL_ID_RE = /^[A-Za-z0-9._:/-]+$/;
15
16
  const AGEND_MCP_CLEANUP_LOCK = ".agend-mcp-cleanup.lock";
16
17
  const AGEND_MCP_CLEANUP_LOCK_STALE_MS = 30_000;
17
18
  const SQLITE_SIDECAR_RE = /-(?:wal|shm|journal)$/;
19
+ function isCodexContextFooter(row) {
20
+ const context = String.raw `Context\s+\d+%\s+(?:left|used)`;
21
+ const legacy = new RegExp(String.raw `^\s*${context}(?:\s+⚠\s+\d+\s+warnings?\b[^\r\n]*)?(?:\s+·\s+\S[^\r\n]*)?\s*$`, "i");
22
+ if (legacy.test(row))
23
+ return true;
24
+ // A narrow Codex 0.156 pane may truncate the context item after the
25
+ // authoritative first `session-id` item. Keep structural readiness while
26
+ // /ctx honestly reports context unavailable from a truncated percentage.
27
+ return /^\s*[0-9a-f-]{36}\s+·\s+Context\b[^\r\n]*$/i.test(row);
28
+ }
18
29
  /**
19
30
  * Remove AgEnD-owned MCP tables from a Codex TOML config without touching
20
31
  * unrelated user settings or third-party MCP servers. Track TOML multiline
@@ -62,6 +73,336 @@ function tomlString(value) {
62
73
  // JSON strings are valid TOML basic strings for the values AgEnD emits.
63
74
  return JSON.stringify(value);
64
75
  }
76
+ /** Codex 0.156 trusts a linked worktree's common repository root, not its CWD. */
77
+ function codexTrustPaths(workingDirectory) {
78
+ const cwd = realpathSync(resolve(workingDirectory));
79
+ try {
80
+ const commonDir = execFileSync("git", ["-C", cwd, "rev-parse", "--path-format=absolute", "--git-common-dir"], {
81
+ encoding: "utf-8", timeout: 2_000, stdio: ["ignore", "pipe", "ignore"],
82
+ }).trim();
83
+ const canonicalCommonDir = realpathSync(commonDir);
84
+ if (basename(canonicalCommonDir) === ".git")
85
+ return { cwd, root: dirname(canonicalCommonDir) };
86
+ // Submodules keep their common dir in another repository's .git/modules.
87
+ const topLevel = execFileSync("git", ["-C", cwd, "rev-parse", "--show-toplevel"], {
88
+ encoding: "utf-8", timeout: 2_000, stdio: ["ignore", "pipe", "ignore"],
89
+ }).trim();
90
+ return { cwd, root: realpathSync(topLevel) };
91
+ }
92
+ catch {
93
+ // A non-Git folder is its own Codex trust root.
94
+ return { cwd, root: cwd };
95
+ }
96
+ }
97
+ function tomlTable(value) {
98
+ return value !== null && typeof value === "object" && !Array.isArray(value) && !(value instanceof Date)
99
+ ? value : null;
100
+ }
101
+ function projectTrustTable(config, root) {
102
+ const projects = tomlTable(tomlTable(config)?.projects);
103
+ return projects ? tomlTable(projects[root]) : null;
104
+ }
105
+ function effectiveProjectTrust(content, root) {
106
+ return projectTrustTable(parseToml(content), root)?.trust_level;
107
+ }
108
+ /** Change only the private project's trust value; parse before and after editing. */
109
+ function setProjectTrusted(content, root) {
110
+ const section = `[projects.${tomlString(root)}]`;
111
+ const parsed = parseToml(content);
112
+ if (projectTrustTable(parsed, root)?.trust_level === "trusted")
113
+ return content;
114
+ const lines = content.split("\n");
115
+ const tableRows = [];
116
+ let multiline = null;
117
+ for (let row = 0; row < lines.length; row++) {
118
+ const line = lines[row];
119
+ if (!multiline && /^\s*\[.*\]\s*(?:#.*)?$/.test(line))
120
+ tableRows.push(row);
121
+ // A multiline config value can quote an entire pane/config. Treat a
122
+ // project-looking row inside it as data, never as effective TOML.
123
+ for (const delimiter of ['"""', "'''"]) {
124
+ if (multiline && multiline !== delimiter)
125
+ continue;
126
+ let count = 0;
127
+ let pos = 0;
128
+ while ((pos = line.indexOf(delimiter, pos)) !== -1) {
129
+ if (delimiter === "'''" || pos === 0 || line[pos - 1] !== "\\")
130
+ count++;
131
+ pos += delimiter.length;
132
+ }
133
+ if (count % 2 === 1)
134
+ multiline = multiline === delimiter ? null : delimiter;
135
+ }
136
+ }
137
+ const headers = tableRows.filter(row => {
138
+ // TOML permits whitespace around dots and quoted/literal table keys.
139
+ // Parse each real header instead of comparing its raw spelling.
140
+ try {
141
+ return projectTrustTable(parseToml(`${lines[row]}\n__agend_trust_probe__ = true\n`), root)?.__agend_trust_probe__ === true;
142
+ }
143
+ catch {
144
+ return false;
145
+ }
146
+ });
147
+ if (headers.length > 1)
148
+ throw new Error("Duplicate Codex trust project table");
149
+ if (headers.length === 0 && projectTrustTable(parsed, root)) {
150
+ throw new Error("Cannot safely edit Codex trust project table");
151
+ }
152
+ let updated;
153
+ if (headers.length === 0) {
154
+ updated = `${content.trimEnd()}\n\n${section}\ntrust_level = "trusted"\n`;
155
+ }
156
+ else {
157
+ const header = headers[0];
158
+ const end = tableRows.find(row => row > header) ?? lines.length;
159
+ const trustRows = [];
160
+ for (let row = header + 1; row < end; row++) {
161
+ if (/^\s*(?:trust_level|"trust_level"|'trust_level')\s*=/.test(lines[row]))
162
+ trustRows.push(row);
163
+ }
164
+ if (trustRows.length > 1)
165
+ throw new Error("Duplicate Codex trust_level key");
166
+ if (trustRows.length === 1)
167
+ lines[trustRows[0]] = 'trust_level = "trusted"';
168
+ else
169
+ lines.splice(header + 1, 0, 'trust_level = "trusted"');
170
+ updated = lines.join("\n");
171
+ }
172
+ // Candidate validation is before atomic write. A spelling the narrow text
173
+ // editor cannot handle must fail closed, never leave Codex with invalid TOML.
174
+ if (effectiveProjectTrust(updated, root) !== "trusted")
175
+ throw new Error("Codex project trust is not effective");
176
+ return updated;
177
+ }
178
+ /** Captured from Codex 0.156; only the live, bottom-of-pane four-choice menu. */
179
+ export function codexResumeDirectoryPromptState(pane) {
180
+ const empty = { active: false, sessionCwd: null, currentCwd: null, safeChoice: false };
181
+ const rows = pane.replace(/\r/g, "").split("\n");
182
+ let last = rows.length - 1;
183
+ while (last >= 0 && rows[last].trim() === "")
184
+ last--;
185
+ let title = -1;
186
+ for (let i = last; i >= Math.max(0, last - 20); i--) {
187
+ if (/^\s{2}Working directory · resume\s*$/.test(rows[i])) {
188
+ title = i;
189
+ break;
190
+ }
191
+ }
192
+ if (title < 0 || !/^\s{2}enter continue · esc use session · ctrl\+c quit\s*$/.test(rows[last]))
193
+ return empty;
194
+ const menu = rows.slice(title + 1, last);
195
+ const one = menu.findIndex(row => /^› 1\. Use session directory \(/.test(row));
196
+ if (one < 0)
197
+ return empty;
198
+ const options = menu.slice(one, one + 4);
199
+ const first = options[0]?.match(/^› 1\. Use session directory \((\/[^)]+)\)$/);
200
+ const second = options[1]?.match(/^ 2\. Use current directory \((\/[^)]+)\)$/);
201
+ const safeChoice = !!first && !!second
202
+ && options[2] === " 3. Always use session directory"
203
+ && options[3] === " 4. Always use current directory"
204
+ && menu.slice(one + 4).every(row => row.trim() === "")
205
+ && menu.slice(0, one).every(row => row.trim() === ""
206
+ || /^\s{2}(?:Session = latest cwd recorded in the resumed session|Current = your current working directory)$/.test(row));
207
+ return { active: true, sessionCwd: first?.[1] ?? null, currentCwd: second?.[1] ?? null, safeChoice };
208
+ }
209
+ /** Unknown variants still own stdin; only the exact canonical menu may be answered. */
210
+ export function codexResumeDirectoryVisible(pane) {
211
+ const rows = pane.replace(/\r/g, "").split("\n");
212
+ let last = rows.length - 1;
213
+ while (last >= 0 && rows[last].trim() === "")
214
+ last--;
215
+ let title = -1;
216
+ for (let i = last; i >= Math.max(0, last - 20); i--) {
217
+ if (/^\s{2}Working directory · resume\s*$/.test(rows[i])) {
218
+ title = i;
219
+ break;
220
+ }
221
+ }
222
+ if (title < 0)
223
+ return false;
224
+ const tail = rows.slice(title + 1, last + 1);
225
+ return tail.some(row => /^\s*[›❯]?\s*1\. Use session directory\b/.test(row))
226
+ && tail.some(row => /^\s*[›❯]?\s*2\. Use current directory\b/.test(row))
227
+ && !tail.some(row => /[›❯]\s*(?:Ask Codex|Message Codex|Type a message)/i.test(row));
228
+ }
229
+ /** Codex's concurrent-owner screen is a hold, never an invitation to press R. */
230
+ export function codexResumeLockActive(pane) {
231
+ const rows = pane.replace(/\r/g, "").split("\n");
232
+ let last = rows.length - 1;
233
+ while (last >= 0 && rows[last].trim() === "")
234
+ last--;
235
+ if (last < 0 || !/^\s*r retry\s+esc\/ctrl\+c\/q exit(?:\s+ctrl\+t transcript)?\s*$/.test(rows[last]))
236
+ return false;
237
+ const recent = rows.slice(Math.max(0, last - 5), last);
238
+ return recent.some(row => /^\s*🔒\s+This conversation is open in another app\b/.test(row))
239
+ && recent.some(row => /^\s*Close it there and press R to continue here\.\s*$/.test(row));
240
+ }
241
+ /** A changed lock-screen footer is still a hold, never a ready prompt. */
242
+ export function codexResumeLockVisible(pane) {
243
+ const rows = pane.replace(/\r/g, "").split("\n");
244
+ let last = rows.length - 1;
245
+ while (last >= 0 && rows[last].trim() === "")
246
+ last--;
247
+ const title = rows.findIndex((row, i) => i >= Math.max(0, last - 8)
248
+ && /^\s*🔒\s+This conversation is open in another app\b/.test(row));
249
+ if (title < 0)
250
+ return false;
251
+ const tail = rows.slice(title + 1, last + 1);
252
+ return tail.some(row => /Close it there and press R to continue here\./.test(row))
253
+ && !tail.some(row => /^\s*[›❯]\s*(?:Ask Codex|Message Codex|Type a message)/i.test(row));
254
+ }
255
+ /** Only the bottom, live Codex 0.156 folder-access screen can own stdin. */
256
+ function codexTrustPromptState(pane) {
257
+ const noPrompt = { active: false, folder: null, root: null, rootNote: "absent", safeChoice: false };
258
+ const rows = pane.replace(/\r/g, "").split("\n");
259
+ let last = rows.length - 1;
260
+ while (last >= 0 && rows[last].trim() === "")
261
+ last--;
262
+ if (last < 0)
263
+ return noPrompt;
264
+ let access = -1;
265
+ for (let index = rows.length - 1; index >= 0; index--) {
266
+ if (/^\s{0,2}Folder access\s*$/.test(rows[index])) {
267
+ access = index;
268
+ break;
269
+ }
270
+ }
271
+ if (access < 0 || last - access > 60)
272
+ return noPrompt;
273
+ const question = rows.findIndex((row, index) => index > access && /^\s{0,2}Trust this folder\?/.test(row));
274
+ if (question < 0 || question >= last)
275
+ return noPrompt;
276
+ // A ready input row or transcript continuation below the question means the
277
+ // trust dialog is history, not the current interactive region.
278
+ if (rows.slice(question + 1, last + 1).some(row => /^\s*[›❯>]\s*(?!\d+\.)\S/.test(row)))
279
+ return noPrompt;
280
+ const noteRows = rows.slice(access + 1, question);
281
+ // The folder must be the first content row after the title. Do not let a
282
+ // later repository-root path stand in for a missing folder path.
283
+ const folderRow = noteRows.find(row => row.trim() !== "");
284
+ const folder = folderRow && /^\s{0,2}\//.test(folderRow) ? folderRow.trim() : null;
285
+ const hasRootNote = noteRows.some(row => /\bNote:|\brepository root\b|Trusting will apply/i.test(row));
286
+ const rootLabel = noteRows.findIndex(row => /\brepository root:/i.test(row));
287
+ const inlineRoot = rootLabel < 0 ? "" : noteRows[rootLabel].split(/\brepository root:/i)[1]?.trim() ?? "";
288
+ const followingRoot = rootLabel < 0 ? "" : noteRows.slice(rootLabel + 1).find(row => row.trim() !== "")?.trim() ?? "";
289
+ const candidateRoot = inlineRoot || followingRoot;
290
+ const root = candidateRoot.startsWith("/") ? candidateRoot : null;
291
+ const rootNote = !hasRootNote ? "absent" : root ? "parsed" : "invalid";
292
+ const choices = rows.slice(question + 1, last + 1).flatMap((row, offset) => {
293
+ const match = row.match(/^\s*([›❯>]?)\s*(\d+)\.\s+(.+?)\s*$/);
294
+ return match ? [{ row: question + 1 + offset, cursor: match[1], number: match[2], text: match[3] }] : [];
295
+ });
296
+ // Unknown option orders, a moved cursor, a third option, or any different
297
+ // footer are held for a human. Never guess where Enter would land.
298
+ const safeChoice = choices.length === 2
299
+ && choices[0].row + 1 === choices[1].row
300
+ && rows.slice(choices[1].row + 1, last).every(row => row.trim() === "")
301
+ && choices[0].cursor === "›" && choices[0].number === "1" && choices[0].text === "Trust and continue"
302
+ && choices[1].cursor === "" && choices[1].number === "2" && choices[1].text === "Quit"
303
+ && /^\s*enter continue\s*·\s*esc quit\s*$/i.test(rows[last]);
304
+ return { active: true, folder, root, rootNote, safeChoice };
305
+ }
306
+ /** Unknown/older trust layouts are still input-blocking, never auto-answered. */
307
+ function codexTrustVariantActive(pane) {
308
+ if (codexTrustPromptState(pane).active)
309
+ return true;
310
+ const rows = pane.replace(/\r/g, "").split("\n");
311
+ let last = rows.length - 1;
312
+ while (last >= 0 && rows[last].trim() === "")
313
+ last--;
314
+ if (last < 0)
315
+ return false;
316
+ const start = Math.max(0, last - 22);
317
+ const folderAccess = rows.findIndex((row, index) => index >= start && /^\s{0,2}Folder access\s*$/.test(row));
318
+ if (folderAccess >= 0) {
319
+ const tail = rows.slice(folderAccess + 1, last + 1);
320
+ if (!tail.some(row => /^\s*[›❯>]\s*(?!\d+\.)\S/.test(row))
321
+ && tail.some(row => /^\s*[›❯>]?\s*\d+\.\s+(?:Open restricted|Trust and continue|Quit)\s*$/i.test(row))
322
+ && /(?:enter|esc|quit|cancel)/i.test(rows[last]))
323
+ return true;
324
+ }
325
+ const question = rows.findIndex((row, index) => index >= start
326
+ && /^\s*(?:Trust this folder\?|Do you trust the files in this folder\?)/i.test(row));
327
+ if (question < 0 || question >= last)
328
+ return false;
329
+ const tail = rows.slice(question + 1, last + 1);
330
+ // A normal input row after a quoted menu makes it history, not live UI.
331
+ if (tail.some(row => /^\s*[›❯>]\s*(?!\d+\.)\S/.test(row)))
332
+ return false;
333
+ return tail.some(row => /^\s*[›❯>]?\s*\d+\.\s+\S/.test(row))
334
+ && (/(?:enter|esc|quit|cancel)/i.test(rows[last])
335
+ || /^\s*[›❯>]?\s*\d+\.\s+\S/.test(rows[last]));
336
+ }
337
+ /** Rate-switch prompts are economic choices, not a fixed keyboard position. */
338
+ function codexRateSwitchVisible(pane) {
339
+ const rows = pane.replace(/\r/g, "").split("\n");
340
+ let last = rows.length - 1;
341
+ while (last >= 0 && rows[last].trim() === "")
342
+ last--;
343
+ let title = -1;
344
+ for (let i = last; i >= Math.max(0, last - 18); i--) {
345
+ if (/^\s*(?:Approaching rate limits|Switch to .{1,120} for lower credit usage\?)\s*$/i.test(rows[i])) {
346
+ title = i;
347
+ break;
348
+ }
349
+ }
350
+ if (title < 0)
351
+ return false;
352
+ const tail = rows.slice(title + 1, last + 1);
353
+ // A copied picker in transcript history is not the currently active menu.
354
+ if (tail.some(row => /^[›>]\s+Ask Codex to do anything\b/.test(row) || isCodexContextFooter(row)))
355
+ return false;
356
+ return tail.some(row => /^\s*[›❯>]?\s*\d+\.\s*(?:Switch to|Keep current model)\b/i.test(row));
357
+ }
358
+ /** Unknown pickers own stdin too; never type or press Enter into one. */
359
+ function codexUnknownSelectionVisible(pane) {
360
+ const rows = pane.replace(/\r/g, "").split("\n");
361
+ let last = rows.length - 1;
362
+ while (last >= 0 && rows[last].trim() === "")
363
+ last--;
364
+ if (last < 0 || !(/\benter\b.*\besc\b/i.test(rows[last])
365
+ || /^\s*Press enter to continue\s*$/i.test(rows[last])))
366
+ return false;
367
+ let selected = -1;
368
+ for (let i = last - 1; i >= Math.max(0, last - 24); i--) {
369
+ if (/^\s*[›❯>]\s+\S/.test(rows[i])) {
370
+ selected = i;
371
+ break;
372
+ }
373
+ }
374
+ if (selected < 0)
375
+ return false;
376
+ if (/^\s*[›❯>]\s+Ask Codex to do anything\b/.test(rows[selected]))
377
+ return false;
378
+ return !rows.slice(selected + 1, last + 1).some(row => /^[›>]\s+Ask Codex to do anything\b/.test(row) || isCodexContextFooter(row));
379
+ }
380
+ /** Only the complete, current Codex installer picker may receive Escape. */
381
+ function codexUpdatePickerVisible(pane) {
382
+ const rows = pane.replace(/\r/g, "").split("\n");
383
+ let last = rows.length - 1;
384
+ while (last >= 0 && rows[last].trim() === "")
385
+ last--;
386
+ if (last < 0 || !/^\s*Press enter to continue\s*$/.test(rows[last]))
387
+ return false;
388
+ for (let first = last - 3; first >= Math.max(0, last - 9); first--) {
389
+ if (!/^\s*[›❯>]\s+1\.\s+Update now\b/.test(rows[first]))
390
+ continue;
391
+ // At 80 columns Codex 0.153 wraps the installer command to the next row.
392
+ // Permit a bounded continuation, but never another option or cursor.
393
+ const second = rows.findIndex((row, index) => index > first && index <= first + 3
394
+ && /^\s*2\.\s+Skip\s*$/.test(row));
395
+ if (second < 0 || !rows.slice(first + 1, second).every(row => /^\s+\S/.test(row) && !/^\s*[›❯>]?\s*\d+\./.test(row))
396
+ || !/^\s*3\.\s+Skip until next version\s*$/.test(rows[second + 1] ?? "")
397
+ || !rows.slice(second + 2, last).every(row => row.trim() === ""))
398
+ continue;
399
+ const intro = rows.slice(Math.max(0, first - 14), first);
400
+ if (intro.some(row => /Update available!/.test(row))
401
+ && intro.some(row => /^\s*Release notes: https:\/\/github\.com\/openai\/codex\/releases\/latest\s*$/.test(row)))
402
+ return true;
403
+ }
404
+ return false;
405
+ }
65
406
  function renderMcpServer(name, entry, instanceName) {
66
407
  const mcpName = `${name}-${instanceName}`.replace(/[^A-Za-z0-9_-]/g, "_");
67
408
  const env = { ...entry.env, AGEND_INSTANCE_NAME: instanceName };
@@ -97,6 +438,49 @@ function atomicWritePrivate(path, content) {
97
438
  catch { }
98
439
  }
99
440
  }
441
+ /** macOS lockf and Linux flock both fail immediately with status 75 on contention. */
442
+ export function codexResumeClaimCommand(platform, lockPath, launch) {
443
+ // A child Codex exit 75 is remapped; only lock contention gets the marker.
444
+ const child = `sh -c ${shellQuote(`${launch}; agend_child_status=$?; if [ "$agend_child_status" -eq 75 ]; then exit 74; fi; exit "$agend_child_status"`)}`;
445
+ const guarded = platform === "darwin"
446
+ ? `lockf -s -t 0 -k -w ${shellQuote(lockPath)} ${child}`
447
+ : `flock -n -E 75 ${shellQuote(lockPath)} ${child}`;
448
+ // Daemon prefixes this command with TERM/AGEND_* assignments. A shell
449
+ // subshell is not a simple command (`VAR=x ( ... )` is a syntax error), but
450
+ // `sh -c` is, so the same claim works in the real daemon launch line.
451
+ return `sh -c ${shellQuote(`${guarded}; agend_resume_status=$?; if [ "$agend_resume_status" -eq 75 ]; then printf '%s\\n' '[agend:codex-session-held]'; fi; exit "$agend_resume_status"`)}`;
452
+ }
453
+ /** Explicit, stopped-instance recovery for an old conversation without an AgEnD owner record. */
454
+ export function attachCodexSession(instanceDir, sharedHome, currentCwd, id) {
455
+ if (!CODEX_SESSION_ID.test(id))
456
+ throw new Error("Codex session ID must be a UUID");
457
+ if (existsSync(join(instanceDir, "window-id")))
458
+ throw new Error("Stop this instance before attaching a Codex session");
459
+ // Startup writes daemon.pid before window-id. Require a clean stop rather
460
+ // than race an instance still launching. An orphaned stale pid marker must
461
+ // be inspected and removed manually, never inferred to be harmless here.
462
+ if (existsSync(join(instanceDir, "daemon.pid")))
463
+ throw new Error("Stop this instance and clear its daemon PID marker before attaching a Codex session");
464
+ const found = codexRolloutForId(sharedHome, id);
465
+ if (!found)
466
+ throw new Error("Codex session ID was not found in the shared session store");
467
+ if (codexTrustPaths(found.cwd).root !== codexTrustPaths(currentCwd).root) {
468
+ throw new Error("Codex session belongs to a different repository; refusing to attach");
469
+ }
470
+ if (codexSessionOwners(id).length > 0)
471
+ throw new Error("Codex session has a live owner; close it before attaching");
472
+ const record = { ...found, owner: basename(instanceDir) };
473
+ atomicWritePrivate(join(instanceDir, "codex-session.json"), JSON.stringify(record));
474
+ atomicWritePrivate(join(instanceDir, "session-id"), id);
475
+ // Human-selected exact identity retires a prior ambiguous-live-pane hold.
476
+ try {
477
+ unlinkSync(join(instanceDir, "codex-session-unconfirmed"));
478
+ }
479
+ catch (err) {
480
+ if (err.code !== "ENOENT")
481
+ throw err;
482
+ }
483
+ }
100
484
  // Account-aware models_cache.json is preferred. These documented Codex models
101
485
  // are only a last-resort menu when the TUI has not populated its cache yet.
102
486
  /** The whole of a codex identity, and the only file a profile owns. */
@@ -111,14 +495,21 @@ const CODEX_FALLBACK_MODELS = [
111
495
  ];
112
496
  export class CodexBackend {
113
497
  instanceDir;
498
+ procRoot;
114
499
  binaryName = "codex";
115
500
  binaryPath;
116
501
  sharedCodexHome;
117
502
  isolatedCodexHome;
118
503
  /** Which subscription this instance runs on, or null for the shared login. */
119
504
  credentialProfile = null;
120
- constructor(instanceDir) {
505
+ /** Set only after preTrust wrote and read back this instance's private config. */
506
+ authorizedTrust = null;
507
+ activePanePid = null;
508
+ resumeRecord = null;
509
+ get unconfirmedSessionPath() { return join(this.instanceDir, "codex-session-unconfirmed"); }
510
+ constructor(instanceDir, procRoot = "/proc") {
121
511
  this.instanceDir = instanceDir;
512
+ this.procRoot = procRoot;
122
513
  this.binaryPath = resolveBinary("codex");
123
514
  this.sharedCodexHome = resolve(process.env.CODEX_HOME?.trim() || join(homedir(), ".codex"));
124
515
  this.isolatedCodexHome = resolve(instanceDir, "codex-home");
@@ -145,6 +536,29 @@ export class CodexBackend {
145
536
  getBottomReadyPattern() {
146
537
  return /^\s*›\s?/;
147
538
  }
539
+ /** Observed on real Codex 0.156.0: the live input row precedes its footer. */
540
+ isDeliveryInputReadyPane(pane) {
541
+ const rows = pane.replace(/\r/g, "").split("\n");
542
+ while (rows.length && !rows[rows.length - 1].trim())
543
+ rows.pop();
544
+ const footer = rows.pop() ?? "";
545
+ // Codex preserves other configured status-line items after our session ID
546
+ // and context meter (observed on 0.156.0: "Context 100% left · GPT-6-Astra").
547
+ // They are footer chrome, not evidence that the input row is unavailable.
548
+ if (!isCodexContextFooter(footer))
549
+ return false;
550
+ // Pasted text may wrap over several continuation rows before the footer.
551
+ // Search only its immediate tail, not a historical transcript prompt.
552
+ for (let i = rows.length - 1; i >= Math.max(0, rows.length - 8); i--) {
553
+ if (/^[>›]\s+\d+\./.test(rows[i]))
554
+ return false;
555
+ if (/^[>›]\s+\S/.test(rows[i]))
556
+ return true;
557
+ if (/^[•■⚠]/.test(rows[i]))
558
+ return false;
559
+ }
560
+ return false;
561
+ }
148
562
  /** Live status chrome that must veto the broad prompt/context ready match. */
149
563
  getBusyPattern() {
150
564
  return /(?:^|\n)•\s+Working\b[^\n]*\besc to interrupt\b/i;
@@ -178,7 +592,7 @@ export class CodexBackend {
178
592
  return false;
179
593
  let footer = -1;
180
594
  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])) {
595
+ if (isCodexContextFooter(rows[i])) {
182
596
  footer = i;
183
597
  break;
184
598
  }
@@ -209,16 +623,21 @@ export class CodexBackend {
209
623
  const approvalFlag = config.skipPermissions !== false
210
624
  ? "--dangerously-bypass-approvals-and-sandbox"
211
625
  : "--full-auto";
212
- // `codex resume --last` resumes the most recent session for the current
213
- // working directory. Each AgEnD instance has a unique working_directory,
214
- // so sessions are per-instance scoped and won't collide.
215
- // If no prior session exists (first launch), Codex falls back to a fresh session.
626
+ // Never select by CWD: two instances can share a worktree and --last may
627
+ // select a session still owned by another app. A sidecar written from the
628
+ // actual pane's open rollout + writer lock is the only automatic identity.
629
+ // A present but unreadable/unknown-version record is NOT legacy absence.
630
+ // Do not overwrite it with a fresh session even if a caller requested a
631
+ // skip-resume recovery; a human must resolve this identity first.
632
+ if (this.hasInvalidSessionIdentity(config.workingDirectory))
633
+ throw new CodexResumeIdentityError();
634
+ this.resumeRecord = config.skipResume ? null : this.validResumeRecord(config.workingDirectory);
216
635
  let cmd;
217
- if (config.skipResume) {
636
+ if (!this.resumeRecord) {
218
637
  cmd = `${this.binaryPath} ${approvalFlag}`;
219
638
  }
220
639
  else {
221
- cmd = `${this.binaryPath} resume --last ${approvalFlag}`;
640
+ cmd = `${this.binaryPath} resume ${shellQuote(this.resumeRecord.id)} ${approvalFlag}`;
222
641
  }
223
642
  if (config.model) {
224
643
  const model = validateModel(config.model);
@@ -232,13 +651,68 @@ export class CodexBackend {
232
651
  // AgEnD instances are unattended processes: an interactive self-update
233
652
  // picker blocks delivery and must never be enabled by a copied global or
234
653
  // managed config layer. Keep this last so the CLI override is authoritative.
235
- cmd += " -c check_for_update_on_startup=false";
654
+ // Both observed Codex 0.155.0 and 0.156.1 support this launch flag. Pin
655
+ // the initial layout so a user/global fullscreen preference cannot make a
656
+ // header-only pane look ready. A later unknown layout still fails closed.
657
+ cmd += " -c check_for_update_on_startup=false --no-alt-screen";
236
658
  // CODEX_HOME is the only Codex-supported way to isolate the complete base
237
659
  // config. A profile only layers over the shared config and would therefore
238
660
  // still load every globally registered AgEnD MCP server.
239
- return `CODEX_HOME=${shellQuote(this.isolatedCodexHome)} ${cmd}`;
661
+ const launch = `CODEX_HOME=${shellQuote(this.isolatedCodexHome)} ${cmd}`;
662
+ if (!this.resumeRecord)
663
+ return launch;
664
+ // The shared claim is held for the entire Codex process lifetime;
665
+ // an atomic, cross-daemon fence closes the race between the owner probe and
666
+ // spawn. Exit 75 is recognized as a held session, never a broken session.
667
+ const claims = join(this.sharedCodexHome, ".agend-session-claims");
668
+ mkdirSync(claims, { recursive: true, mode: 0o700 });
669
+ return codexResumeClaimCommand(process.platform, join(claims, `${this.resumeRecord.id}.lock`), launch);
670
+ }
671
+ setActivePanePid(pid) { this.activePanePid = pid; }
672
+ /** A fresh launch has no resume identity; it must not be counted as --resume. */
673
+ canResume(workingDirectory) { return this.validResumeRecord(workingDirectory) !== null; }
674
+ hasSessionIdentity() {
675
+ return existsSync(join(this.instanceDir, "codex-session.json")) || existsSync(join(this.instanceDir, "session-id"))
676
+ || existsSync(this.unconfirmedSessionPath);
677
+ }
678
+ hasInvalidSessionIdentity(workingDirectory) {
679
+ return existsSync(this.unconfirmedSessionPath) || (this.hasSessionIdentity() && !this.validResumeRecord(workingDirectory));
680
+ }
681
+ hasUnconfirmedSessionIdentity() { return existsSync(this.unconfirmedSessionPath); }
682
+ /** Positive owner evidence, not merely a stale lock-file name on disk. */
683
+ resumeOwner(workingDirectory) {
684
+ const record = this.validResumeRecord(workingDirectory);
685
+ return record ? codexSessionOwners(record.id, this.procRoot).find(pid => pid !== process.pid) ?? null : null;
686
+ }
687
+ validResumeRecord(workingDirectory) {
688
+ if (existsSync(this.unconfirmedSessionPath))
689
+ return null;
690
+ try {
691
+ const record = JSON.parse(readFileSync(join(this.instanceDir, "codex-session.json"), "utf8"));
692
+ if (!record || !CODEX_SESSION_ID.test(record.id) || record.owner !== basename(this.instanceDir))
693
+ return null;
694
+ if (readFileSync(join(this.instanceDir, "session-id"), "utf8").trim() !== record.id)
695
+ return null;
696
+ const rollout = realpathSync(record.rolloutPath);
697
+ const sessions = realpathSync(join(this.sharedCodexHome, "sessions"));
698
+ if (!rollout.startsWith(`${sessions}/`))
699
+ return null;
700
+ const meta = readCodexRolloutMeta(rollout);
701
+ if (!meta || meta.id !== record.id || meta.cwd !== record.cwd)
702
+ return null;
703
+ // A moved worktree may legitimately have a different CWD in the saved
704
+ // session. It must still be the same Git repository as the current CWD.
705
+ const current = codexTrustPaths(workingDirectory);
706
+ if (record.cwd !== current.cwd && codexTrustPaths(record.cwd).root !== current.root)
707
+ return null;
708
+ return record;
709
+ }
710
+ catch {
711
+ return null;
712
+ }
240
713
  }
241
714
  writeConfig(config) {
715
+ this.authorizedTrust = null;
242
716
  // Set before the home is prepared: which login this instance gets is a
243
717
  // property of the home, and the home is built here.
244
718
  this.credentialProfile = this.readProfile(config);
@@ -324,16 +798,10 @@ export class CodexBackend {
324
798
  catch { /* best effort */ }
325
799
  }
326
800
  /**
327
- * Ensure Codex's TUI status line shows context usage so /ctx can scrape it.
328
- * Rules (never overwrites the user's status_line):
329
- * 1. status_line already has a context item (context-remaining / -usage /
330
- * -used) → leave the whole config untouched (they already show context).
331
- * 2. no context item:
332
- * - no status_line at all → write status_line = ["context-remaining"]
333
- * - status_line exists → append "context-remaining" to it
334
- * If a user's own status_line is long and truncates at 80 cols, that's their
335
- * config — /ctx just reports context unavailable. Best-effort string edit of
336
- * ~/.codex/config.toml (no toml dependency); other settings untouched.
801
+ * The first status-line item is Codex's own current session ID. Unlike fd
802
+ * order, this changes when /new switches chats while old writer locks stay
803
+ * open. Keep context too, then preserve all user-selected remaining items.
804
+ * If the footer is hidden/truncated, checkpointing fails closed instead.
337
805
  */
338
806
  enableContextStatusLine() {
339
807
  const configPath = join(this.isolatedCodexHome, "config.toml");
@@ -342,31 +810,48 @@ export class CodexBackend {
342
810
  content = readFileSync(configPath, "utf-8");
343
811
  }
344
812
  catch { /* no file yet */ }
345
- // Rule 1: any existing context item → don't touch anything.
346
- if (/status_line\s*=\s*\[[^\]]*context-(remaining|usage|used)[^\]]*\]/.test(content))
813
+ let existing;
814
+ try {
815
+ const parsed = parseToml(content);
816
+ if (parsed.tui?.status_line !== undefined) {
817
+ if (!Array.isArray(parsed.tui.status_line)
818
+ || !parsed.tui.status_line.every((item) => typeof item === "string"))
819
+ return;
820
+ existing = parsed.tui.status_line;
821
+ }
822
+ }
823
+ catch {
347
824
  return;
348
- const ITEM = "context-remaining";
349
- const arr = content.match(/status_line\s*=\s*\[([^\]]*)\]/);
825
+ }
826
+ const tuiHeader = /^[ \t]*\[[ \t]*tui[ \t]*\][ \t]*(?:#.*)?$/m.exec(content);
827
+ const tuiStart = tuiHeader ? tuiHeader.index + tuiHeader[0].length : -1;
828
+ const nextHeader = tuiStart >= 0 ? /^[ \t]*\[/m.exec(content.slice(tuiStart)) : null;
829
+ const tuiEnd = nextHeader ? tuiStart + nextHeader.index : content.length;
830
+ const tuiBody = tuiStart >= 0 ? content.slice(tuiStart, tuiEnd) : "";
831
+ const arr = /^[ \t]*status_line[ \t]*=[ \t]*\[([^\]]*)\]/m.exec(tuiBody);
832
+ if (existing && !arr)
833
+ return; // an unfamiliar but valid TOML form: preserve it
350
834
  if (arr) {
351
- // Rule 2b: prepend our item to the user's existing array (don't overwrite).
352
- // First position keeps "Context N% left" at the far left of the footer so a
353
- // long cwd/other items can't push it past 80 cols and truncate it.
354
- const inner = arr[1].trim().replace(/^,\s*/, "").replace(/,\s*$/, "");
355
- const newInner = inner.length ? `"${ITEM}", ${inner}` : `"${ITEM}"`;
356
- content = content.replace(arr[0], `status_line = [${newInner}]`);
835
+ const items = existing;
836
+ const context = items.find(item => /^(?:context-remaining|context-usage|context-used)$/.test(item)) ?? "context-remaining";
837
+ const ordered = ["session-id", context, ...items.filter(item => item !== "session-id" && item !== context)];
838
+ const updatedBody = tuiBody.replace(arr[0], `\nstatus_line = ${JSON.stringify(ordered)}`);
839
+ content = content.slice(0, tuiStart) + updatedBody + content.slice(tuiEnd);
357
840
  }
358
841
  else {
359
- // Rule 2a: no status_line at all → add a minimal one.
360
842
  if (content.length && !content.endsWith("\n"))
361
843
  content += "\n";
362
- if (/^\[tui\]/m.test(content)) {
363
- content = content.replace(/^\[tui\][^\n]*\n/m, h => `${h}status_line = ["${ITEM}"]\n`);
844
+ if (tuiHeader) {
845
+ content = content.slice(0, tuiStart) + '\nstatus_line = ["session-id", "context-remaining"]' + content.slice(tuiStart);
364
846
  }
365
847
  else {
366
- content += `\n[tui]\nstatus_line = ["${ITEM}"]\n`;
848
+ content += '\n[tui]\nstatus_line = ["session-id", "context-remaining"]\n';
367
849
  }
368
850
  }
369
851
  try {
852
+ // A bad rewrite must not turn a working Codex configuration into a
853
+ // startup failure. It merely loses the optional current-ID proof.
854
+ parseToml(content);
370
855
  atomicWritePrivate(configPath, content);
371
856
  }
372
857
  catch { /* best effort — never block launch on statusline config */ }
@@ -401,16 +886,23 @@ export class CodexBackend {
401
886
  return home;
402
887
  }
403
888
  preTrust(workDir) {
889
+ this.authorizedTrust = null;
890
+ const paths = codexTrustPaths(workDir);
404
891
  const configPath = join(this.isolatedCodexHome, "config.toml");
405
892
  let content = "";
406
893
  try {
407
894
  content = readFileSync(configPath, "utf-8");
408
895
  }
409
896
  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`);
897
+ const updated = setProjectTrusted(content, paths.root);
898
+ if (updated !== content)
899
+ atomicWritePrivate(configPath, updated);
900
+ // Do not authorize an automatic Enter merely because the write returned:
901
+ // a stale/untrusted section in the effective isolated config must fail shut.
902
+ const onDisk = readFileSync(configPath, "utf-8");
903
+ if (effectiveProjectTrust(onDisk, paths.root) !== "trusted")
904
+ throw new Error("Codex project trust did not persist");
905
+ this.authorizedTrust = paths;
414
906
  }
415
907
  /**
416
908
  * Preserve Codex login/session/cache behavior while isolating config.toml.
@@ -654,11 +1146,20 @@ export class CodexBackend {
654
1146
  }
655
1147
  }
656
1148
  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;
1149
+ // Header and context text persist in inline scrollback and even while the
1150
+ // CLI is loading or a modal owns stdin. Require the live prompt followed
1151
+ // by the Context footer at the *end* of the capture. getBusyPattern still
1152
+ // vetoes a working turn whose empty composer remains visible. Unknown TUI
1153
+ // layouts cannot claim readiness by merely rendering the old header.
1154
+ // U+22C6 is Codex's observed cosmetic starfield; it can be drawn in the
1155
+ // prompt, between prompt/footer, and below the footer. A drafted composer
1156
+ // is also idle once this same bottom footer proves it owns the screen.
1157
+ 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;
1158
+ }
1159
+ /** A proxy reply filters chrome per line; whole-pane readiness is separate. */
1160
+ isProxyReplyChromeLine(line) {
1161
+ return /^\s*[›>]\s+Ask Codex to do anything\s*$/.test(line)
1162
+ || isCodexContextFooter(line);
662
1163
  }
663
1164
  getErrorPatterns() {
664
1165
  return [
@@ -756,12 +1257,79 @@ export class CodexBackend {
756
1257
  ];
757
1258
  }
758
1259
  getStartupDialogs() {
1260
+ const trustHold = this.trustHoldDialog();
759
1261
  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" },
1262
+ {
1263
+ pattern: /^\s{2}Working directory · resume\s*$/m,
1264
+ keys: ["Down", "Enter"],
1265
+ description: "Codex verified resume directory — use this instance's current worktree",
1266
+ blocksDelivery: true,
1267
+ inputBlocked: true,
1268
+ autoResolutionKey: "codex-verified-resume-directory",
1269
+ isActive: pane => {
1270
+ const state = codexResumeDirectoryPromptState(pane);
1271
+ const record = this.resumeRecord;
1272
+ const authorized = this.authorizedTrust;
1273
+ return state.active && state.safeChoice && !!record && !!authorized
1274
+ && state.sessionCwd === record.cwd && state.currentCwd === authorized.cwd;
1275
+ },
1276
+ },
1277
+ this.resumeDirectoryHoldDialog(),
1278
+ this.resumeLockHoldDialog(),
1279
+ {
1280
+ pattern: /^\s*Trust this folder\?/m,
1281
+ keys: ["Enter"],
1282
+ description: "Codex authorized folder trust dialog",
1283
+ blocksDelivery: true,
1284
+ inputBlocked: true,
1285
+ autoResolutionKey: "codex-authorized-folder-trust",
1286
+ isActive: pane => {
1287
+ const state = codexTrustPromptState(pane);
1288
+ const authorized = this.authorizedTrust;
1289
+ return state.active && state.safeChoice && authorized !== null
1290
+ && state.folder === authorized.cwd
1291
+ && (state.rootNote === "absent" ? authorized.root === authorized.cwd
1292
+ : state.rootNote === "parsed" && state.root === authorized.root);
1293
+ },
1294
+ },
1295
+ trustHold,
762
1296
  this.updatePickerDialog(),
1297
+ this.unknownSelectionHoldDialog(),
763
1298
  ];
764
1299
  }
1300
+ trustHoldDialog() {
1301
+ return {
1302
+ pattern: /^\s*(?:Folder access|Trust this folder\?|Do you trust the files in this folder\?)/im,
1303
+ keys: [],
1304
+ description: "Codex folder trust needs human confirmation",
1305
+ holdOnly: true,
1306
+ blocksDelivery: true,
1307
+ inputBlocked: true,
1308
+ isActive: codexTrustVariantActive,
1309
+ };
1310
+ }
1311
+ resumeDirectoryHoldDialog() {
1312
+ return {
1313
+ pattern: /^\s{2}Working directory · resume\s*$/m,
1314
+ keys: [],
1315
+ description: "Codex resume directory needs verified session/worktree ownership",
1316
+ holdOnly: true,
1317
+ blocksDelivery: true,
1318
+ inputBlocked: true,
1319
+ isActive: codexResumeDirectoryVisible,
1320
+ };
1321
+ }
1322
+ resumeLockHoldDialog() {
1323
+ return {
1324
+ pattern: /This conversation is open in another app/,
1325
+ keys: [],
1326
+ description: "Codex conversation is open in another app — close that owner before a manual restart",
1327
+ holdOnly: true,
1328
+ blocksDelivery: true,
1329
+ inputBlocked: true,
1330
+ isActive: codexResumeLockVisible,
1331
+ };
1332
+ }
765
1333
  updatePickerDialog() {
766
1334
  return {
767
1335
  // Defense in depth for config written by older AgEnD versions or a Codex
@@ -777,26 +1345,43 @@ export class CodexBackend {
777
1345
  // seconds and a delivery can arrive first (hit live on codex-cli 0.153.4,
778
1346
  // which parks on this picker for as long as nobody answers it).
779
1347
  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
- },
1348
+ // Daemon.dialogMatches uses isActive INSTEAD OF pattern when present.
1349
+ // Check the complete current picker here; a different Enter-only menu
1350
+ // must never receive this automatic Escape key.
1351
+ isActive: codexUpdatePickerVisible,
1352
+ };
1353
+ }
1354
+ unknownSelectionHoldDialog() {
1355
+ return {
1356
+ pattern: /^\s*[›❯>]\s+\S/m,
1357
+ keys: [],
1358
+ description: "Codex interactive selection needs human input",
1359
+ holdOnly: true,
1360
+ blocksDelivery: true,
1361
+ inputBlocked: true,
1362
+ isActive: codexUnknownSelectionVisible,
788
1363
  };
789
1364
  }
790
1365
  getRuntimeDialogs() {
791
1366
  return [
1367
+ this.trustHoldDialog(),
1368
+ this.resumeDirectoryHoldDialog(),
1369
+ this.resumeLockHoldDialog(),
792
1370
  {
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",
1371
+ // Codex 0.156 may change the wording/order of this credit-cost choice.
1372
+ // Never navigate it by position: a moved option could switch to a
1373
+ // more expensive model. A live picker holds delivery and notifies a
1374
+ // human; old transcript mentions are excluded by the tail matcher.
1375
+ pattern: /(?:Approaching rate limits|Switch to [^\r\n]{1,120} for lower credit usage\?)/i,
1376
+ keys: [],
1377
+ description: "Codex rate limit model switch dialog needs human choice",
1378
+ holdOnly: true,
1379
+ blocksDelivery: true,
1380
+ inputBlocked: true,
1381
+ isActive: codexRateSwitchVisible,
798
1382
  },
799
1383
  this.updatePickerDialog(),
1384
+ this.unknownSelectionHoldDialog(),
800
1385
  ];
801
1386
  }
802
1387
  getInputUnavailableTransients() {
@@ -835,10 +1420,60 @@ export class CodexBackend {
835
1420
  getContextUsage() {
836
1421
  return null;
837
1422
  }
838
- getSessionId() {
839
- // Codex manages sessions internally via SQLite (~/.codex/state_5.sqlite).
840
- // `resume --last` handles session selection by CWD automatically.
841
- return null;
1423
+ getSessionId(pane) {
1424
+ const panePid = this.activePanePid;
1425
+ if (!panePid)
1426
+ return null;
1427
+ const candidates = codexSessionsForPane(panePid, this.sharedCodexHome, this.procRoot);
1428
+ const displayedId = pane === undefined ? null : codexCurrentSessionFromPane(pane);
1429
+ // Once ambiguity has revoked the old identity, only fresh visible proof
1430
+ // can restore it. A later status callback without a pane cannot silently
1431
+ // re-arm the old sidecar just because one fd happened to close.
1432
+ if (existsSync(this.unconfirmedSessionPath) && !displayedId)
1433
+ return null;
1434
+ const active = candidates.length === 1
1435
+ ? displayedId && displayedId !== candidates[0].id ? null : candidates[0]
1436
+ : displayedId ? candidates.find(candidate => candidate.id === displayedId) ?? null : null;
1437
+ if (!active) {
1438
+ // `/new` keeps both native writer locks open even after a completed
1439
+ // turn. A null checkpoint must revoke the old resumable sidecar, not
1440
+ // leave it armed for a later wake into the wrong conversation.
1441
+ const oldId = (() => {
1442
+ try {
1443
+ return readFileSync(join(this.instanceDir, "session-id"), "utf8").trim();
1444
+ }
1445
+ catch {
1446
+ return null;
1447
+ }
1448
+ })();
1449
+ const hasStoredIdentity = existsSync(join(this.instanceDir, "codex-session.json")) || oldId !== null;
1450
+ if (candidates.length > 1 || (displayedId && displayedId !== oldId)
1451
+ || (pane !== undefined && candidates.length === 0 && hasStoredIdentity)) {
1452
+ try {
1453
+ writeFileSync(this.unconfirmedSessionPath, "current Codex session unconfirmed\n", { flag: "wx", mode: 0o600 });
1454
+ }
1455
+ catch (err) {
1456
+ if (err.code !== "EEXIST")
1457
+ throw err;
1458
+ }
1459
+ }
1460
+ return null;
1461
+ }
1462
+ const record = { ...active, owner: basename(this.instanceDir) };
1463
+ const path = join(this.instanceDir, "codex-session.json");
1464
+ try {
1465
+ const prior = readFileSync(path, "utf8");
1466
+ if (prior === JSON.stringify(record)) {
1467
+ if (existsSync(this.unconfirmedSessionPath))
1468
+ unlinkSync(this.unconfirmedSessionPath);
1469
+ return active.id;
1470
+ }
1471
+ }
1472
+ catch { /* first checkpoint */ }
1473
+ atomicWritePrivate(path, JSON.stringify(record));
1474
+ if (existsSync(this.unconfirmedSessionPath))
1475
+ unlinkSync(this.unconfirmedSessionPath);
1476
+ return active.id;
842
1477
  }
843
1478
  getQuitCommand() { return "/quit"; }
844
1479
  getCompactCommand() { return "/compact"; }