@songsid/agend 2.1.6-beta.9 → 2.1.6

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 (45) hide show
  1. package/dist/backend/codex.d.ts +35 -0
  2. package/dist/backend/codex.js +588 -36
  3. package/dist/backend/codex.js.map +1 -1
  4. package/dist/backend/kiro.d.ts +9 -0
  5. package/dist/backend/kiro.js +129 -0
  6. package/dist/backend/kiro.js.map +1 -1
  7. package/dist/backend/muse.d.ts +37 -4
  8. package/dist/backend/muse.js +150 -61
  9. package/dist/backend/muse.js.map +1 -1
  10. package/dist/backend/types.d.ts +15 -1
  11. package/dist/backend/types.js.map +1 -1
  12. package/dist/daemon.d.ts +98 -10
  13. package/dist/daemon.js +836 -94
  14. package/dist/daemon.js.map +1 -1
  15. package/dist/fleet-manager.d.ts +30 -1
  16. package/dist/fleet-manager.js +152 -47
  17. package/dist/fleet-manager.js.map +1 -1
  18. package/dist/instance-lifecycle.d.ts +17 -0
  19. package/dist/instance-lifecycle.js +118 -5
  20. package/dist/instance-lifecycle.js.map +1 -1
  21. package/dist/locale.js +16 -0
  22. package/dist/locale.js.map +1 -1
  23. package/dist/muse-usage-relay.d.ts +72 -0
  24. package/dist/muse-usage-relay.js +431 -0
  25. package/dist/muse-usage-relay.js.map +1 -0
  26. package/dist/tmux-manager.d.ts +2 -0
  27. package/dist/tmux-manager.js +20 -0
  28. package/dist/tmux-manager.js.map +1 -1
  29. package/dist/tool-permissions.d.ts +27 -0
  30. package/dist/tool-permissions.js +64 -3
  31. package/dist/tool-permissions.js.map +1 -1
  32. package/dist/topic-commands.d.ts +9 -0
  33. package/dist/topic-commands.js +36 -2
  34. package/dist/topic-commands.js.map +1 -1
  35. package/dist/ui/view.html +2 -2
  36. package/dist/usage/i18n-keys.d.ts +1 -1
  37. package/dist/usage/i18n-keys.js +1 -1
  38. package/dist/usage/i18n-keys.js.map +1 -1
  39. package/dist/usage/providers.d.ts +4 -0
  40. package/dist/usage/providers.js +86 -9
  41. package/dist/usage/providers.js.map +1 -1
  42. package/dist/usage/usage-api.d.ts +0 -11
  43. package/dist/usage/usage-api.js +50 -8
  44. package/dist/usage/usage-api.js.map +1 -1
  45. package/package.json +2 -1
@@ -1,17 +1,33 @@
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 { createHash } from "node:crypto";
4
+ import { execFile, execFileSync } from "node:child_process";
4
5
  import { promisify } from "node:util";
5
- import { lastNonBlankRow } from "../pane-input-residue.js";
6
6
  import { basename, dirname, join, resolve } from "node:path";
7
7
  import { probeCliVersion, resolveBinary, shellQuote, validateModel, validateProvider, warnIfModelMismatch } from "./types.js";
8
8
  import { credentialHomeSpec, credentialProfileHome, prepareCredentialProfileHome, resolveCredentialProfile, } from "./credential-profile.js";
9
9
  import { getAgendHome } from "../paths.js";
10
10
  import { appendWithMarker, removeMarker } from "./marker-utils.js";
11
11
  import { t } from "../locale.js";
12
+ import { parse as parseToml } from "smol-toml";
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._:/-]+$/;
16
+ /**
17
+ * Whether a pane row is Codex's context footer. Kept when #913 was reverted:
18
+ * #913 introduced it, but #914's pane/ready detection is built on it, and it
19
+ * is pane parsing, not session handling.
20
+ */
21
+ function isCodexContextFooter(row) {
22
+ const context = String.raw `Context\s+\d+%\s+(?:left|used)`;
23
+ const legacy = new RegExp(String.raw `^\s*${context}(?:\s+⚠\s+\d+\s+warnings?\b[^\r\n]*)?(?:\s+·\s+\S[^\r\n]*)?\s*$`, "i");
24
+ if (legacy.test(row))
25
+ return true;
26
+ // A narrow Codex 0.156 pane may truncate the context item after the
27
+ // authoritative first `session-id` item. Keep structural readiness while
28
+ // /ctx honestly reports context unavailable from a truncated percentage.
29
+ return /^\s*[0-9a-f-]{36}\s+·\s+Context\b[^\r\n]*$/i.test(row);
30
+ }
15
31
  const AGEND_MCP_CLEANUP_LOCK = ".agend-mcp-cleanup.lock";
16
32
  const AGEND_MCP_CLEANUP_LOCK_STALE_MS = 30_000;
17
33
  const SQLITE_SIDECAR_RE = /-(?:wal|shm|journal)$/;
@@ -62,6 +78,322 @@ function tomlString(value) {
62
78
  // JSON strings are valid TOML basic strings for the values AgEnD emits.
63
79
  return JSON.stringify(value);
64
80
  }
81
+ /** Codex 0.156 trusts a linked worktree's common repository root, not its CWD. */
82
+ function codexTrustPaths(workingDirectory) {
83
+ const cwd = realpathSync(resolve(workingDirectory));
84
+ try {
85
+ const commonDir = execFileSync("git", ["-C", cwd, "rev-parse", "--path-format=absolute", "--git-common-dir"], {
86
+ encoding: "utf-8", timeout: 2_000, stdio: ["ignore", "pipe", "ignore"],
87
+ }).trim();
88
+ const canonicalCommonDir = realpathSync(commonDir);
89
+ if (basename(canonicalCommonDir) === ".git")
90
+ return { cwd, root: dirname(canonicalCommonDir) };
91
+ // Submodules keep their common dir in another repository's .git/modules.
92
+ const topLevel = execFileSync("git", ["-C", cwd, "rev-parse", "--show-toplevel"], {
93
+ encoding: "utf-8", timeout: 2_000, stdio: ["ignore", "pipe", "ignore"],
94
+ }).trim();
95
+ return { cwd, root: realpathSync(topLevel) };
96
+ }
97
+ catch {
98
+ // A non-Git folder is its own Codex trust root.
99
+ return { cwd, root: cwd };
100
+ }
101
+ }
102
+ function tomlTable(value) {
103
+ return value !== null && typeof value === "object" && !Array.isArray(value) && !(value instanceof Date)
104
+ ? value : null;
105
+ }
106
+ function projectTrustTable(config, root) {
107
+ const projects = tomlTable(tomlTable(config)?.projects);
108
+ return projects ? tomlTable(projects[root]) : null;
109
+ }
110
+ function effectiveProjectTrust(content, root) {
111
+ return projectTrustTable(parseToml(content), root)?.trust_level;
112
+ }
113
+ /** Change only the private project's trust value; parse before and after editing. */
114
+ function setProjectTrusted(content, root) {
115
+ const section = `[projects.${tomlString(root)}]`;
116
+ const parsed = parseToml(content);
117
+ if (projectTrustTable(parsed, root)?.trust_level === "trusted")
118
+ return content;
119
+ const lines = content.split("\n");
120
+ const tableRows = [];
121
+ let multiline = null;
122
+ for (let row = 0; row < lines.length; row++) {
123
+ const line = lines[row];
124
+ if (!multiline && /^\s*\[.*\]\s*(?:#.*)?$/.test(line))
125
+ tableRows.push(row);
126
+ // A multiline config value can quote an entire pane/config. Treat a
127
+ // project-looking row inside it as data, never as effective TOML.
128
+ for (const delimiter of ['"""', "'''"]) {
129
+ if (multiline && multiline !== delimiter)
130
+ continue;
131
+ let count = 0;
132
+ let pos = 0;
133
+ while ((pos = line.indexOf(delimiter, pos)) !== -1) {
134
+ if (delimiter === "'''" || pos === 0 || line[pos - 1] !== "\\")
135
+ count++;
136
+ pos += delimiter.length;
137
+ }
138
+ if (count % 2 === 1)
139
+ multiline = multiline === delimiter ? null : delimiter;
140
+ }
141
+ }
142
+ const headers = tableRows.filter(row => {
143
+ // TOML permits whitespace around dots and quoted/literal table keys.
144
+ // Parse each real header instead of comparing its raw spelling.
145
+ try {
146
+ return projectTrustTable(parseToml(`${lines[row]}\n__agend_trust_probe__ = true\n`), root)?.__agend_trust_probe__ === true;
147
+ }
148
+ catch {
149
+ return false;
150
+ }
151
+ });
152
+ if (headers.length > 1)
153
+ throw new Error("Duplicate Codex trust project table");
154
+ if (headers.length === 0 && projectTrustTable(parsed, root)) {
155
+ throw new Error("Cannot safely edit Codex trust project table");
156
+ }
157
+ let updated;
158
+ if (headers.length === 0) {
159
+ updated = `${content.trimEnd()}\n\n${section}\ntrust_level = "trusted"\n`;
160
+ }
161
+ else {
162
+ const header = headers[0];
163
+ const end = tableRows.find(row => row > header) ?? lines.length;
164
+ const trustRows = [];
165
+ for (let row = header + 1; row < end; row++) {
166
+ if (/^\s*(?:trust_level|"trust_level"|'trust_level')\s*=/.test(lines[row]))
167
+ trustRows.push(row);
168
+ }
169
+ if (trustRows.length > 1)
170
+ throw new Error("Duplicate Codex trust_level key");
171
+ if (trustRows.length === 1)
172
+ lines[trustRows[0]] = 'trust_level = "trusted"';
173
+ else
174
+ lines.splice(header + 1, 0, 'trust_level = "trusted"');
175
+ updated = lines.join("\n");
176
+ }
177
+ // Candidate validation is before atomic write. A spelling the narrow text
178
+ // editor cannot handle must fail closed, never leave Codex with invalid TOML.
179
+ if (effectiveProjectTrust(updated, root) !== "trusted")
180
+ throw new Error("Codex project trust is not effective");
181
+ return updated;
182
+ }
183
+ /** Only the bottom, live Codex 0.156 folder-access screen can own stdin. */
184
+ function codexTrustPromptState(pane) {
185
+ const noPrompt = { active: false, folder: null, root: null, rootNote: "absent", safeChoice: false };
186
+ const rows = pane.replace(/\r/g, "").split("\n");
187
+ let last = rows.length - 1;
188
+ while (last >= 0 && rows[last].trim() === "")
189
+ last--;
190
+ if (last < 0)
191
+ return noPrompt;
192
+ let access = -1;
193
+ for (let index = rows.length - 1; index >= 0; index--) {
194
+ if (/^\s{0,2}Folder access\s*$/.test(rows[index])) {
195
+ access = index;
196
+ break;
197
+ }
198
+ }
199
+ if (access < 0 || last - access > 60)
200
+ return noPrompt;
201
+ const question = rows.findIndex((row, index) => index > access && /^\s{0,2}Trust this folder\?/.test(row));
202
+ if (question < 0 || question >= last)
203
+ return noPrompt;
204
+ // A ready input row or transcript continuation below the question means the
205
+ // trust dialog is history, not the current interactive region.
206
+ if (rows.slice(question + 1, last + 1).some(row => /^\s*[›❯>]\s*(?!\d+\.)\S/.test(row)))
207
+ return noPrompt;
208
+ const noteRows = rows.slice(access + 1, question);
209
+ // The folder must be the first content row after the title. Do not let a
210
+ // later repository-root path stand in for a missing folder path.
211
+ const folderRow = noteRows.find(row => row.trim() !== "");
212
+ const folder = folderRow && /^\s{0,2}\//.test(folderRow) ? folderRow.trim() : null;
213
+ const hasRootNote = noteRows.some(row => /\bNote:|\brepository root\b|Trusting will apply/i.test(row));
214
+ const rootLabel = noteRows.findIndex(row => /\brepository root:/i.test(row));
215
+ const inlineRoot = rootLabel < 0 ? "" : noteRows[rootLabel].split(/\brepository root:/i)[1]?.trim() ?? "";
216
+ const followingRoot = rootLabel < 0 ? "" : noteRows.slice(rootLabel + 1).find(row => row.trim() !== "")?.trim() ?? "";
217
+ const candidateRoot = inlineRoot || followingRoot;
218
+ const root = candidateRoot.startsWith("/") ? candidateRoot : null;
219
+ const rootNote = !hasRootNote ? "absent" : root ? "parsed" : "invalid";
220
+ const choices = rows.slice(question + 1, last + 1).flatMap((row, offset) => {
221
+ const match = row.match(/^\s*([›❯>]?)\s*(\d+)\.\s+(.+?)\s*$/);
222
+ return match ? [{ row: question + 1 + offset, cursor: match[1], number: match[2], text: match[3] }] : [];
223
+ });
224
+ // Unknown option orders, a moved cursor, a third option, or any different
225
+ // footer are held for a human. Never guess where Enter would land.
226
+ const safeChoice = choices.length === 2
227
+ && choices[0].row + 1 === choices[1].row
228
+ && rows.slice(choices[1].row + 1, last).every(row => row.trim() === "")
229
+ && choices[0].cursor === "›" && choices[0].number === "1" && choices[0].text === "Trust and continue"
230
+ && choices[1].cursor === "" && choices[1].number === "2" && choices[1].text === "Quit"
231
+ && /^\s*enter continue\s*·\s*esc quit\s*$/i.test(rows[last]);
232
+ return { active: true, folder, root, rootNote, safeChoice };
233
+ }
234
+ /** Unknown/older trust layouts are still input-blocking, never auto-answered. */
235
+ function codexTrustVariantActive(pane) {
236
+ if (codexTrustPromptState(pane).active)
237
+ return true;
238
+ const rows = pane.replace(/\r/g, "").split("\n");
239
+ let last = rows.length - 1;
240
+ while (last >= 0 && rows[last].trim() === "")
241
+ last--;
242
+ if (last < 0)
243
+ return false;
244
+ const start = Math.max(0, last - 22);
245
+ const folderAccess = rows.findIndex((row, index) => index >= start && /^\s{0,2}Folder access\s*$/.test(row));
246
+ if (folderAccess >= 0) {
247
+ const tail = rows.slice(folderAccess + 1, last + 1);
248
+ if (!tail.some(row => /^\s*[›❯>]\s*(?!\d+\.)\S/.test(row))
249
+ && tail.some(row => /^\s*[›❯>]?\s*\d+\.\s+(?:Open restricted|Trust and continue|Quit)\s*$/i.test(row))
250
+ && /(?:enter|esc|quit|cancel)/i.test(rows[last]))
251
+ return true;
252
+ }
253
+ const question = rows.findIndex((row, index) => index >= start
254
+ && /^\s*(?:Trust this folder\?|Do you trust the files in this folder\?)/i.test(row));
255
+ if (question < 0 || question >= last)
256
+ return false;
257
+ const tail = rows.slice(question + 1, last + 1);
258
+ // A normal input row after a quoted menu makes it history, not live UI.
259
+ if (tail.some(row => /^\s*[›❯>]\s*(?!\d+\.)\S/.test(row)))
260
+ return false;
261
+ return tail.some(row => /^\s*[›❯>]?\s*\d+\.\s+\S/.test(row))
262
+ && (/(?:enter|esc|quit|cancel)/i.test(rows[last])
263
+ || /^\s*[›❯>]?\s*\d+\.\s+\S/.test(rows[last]));
264
+ }
265
+ /**
266
+ * Structural helpers for the codex usage-limit selection menu (#945).
267
+ *
268
+ * The real codex 0.156.1 dialog (captured live) states that codex has
269
+ * ALREADY switched to Luna Reserve and offers:
270
+ *
271
+ * • Automatically switched to Luna Reserve xhigh due to usage limits.
272
+ * You're now using Luna, a faster model for simpler tasks.
273
+ * Use your reset to continue using the most advanced models, or
274
+ * wait for usage to reset after 08:00 on 27 Sep.
275
+ * › 1. Reset usage
276
+ * 2. Add Credits
277
+ * 3. Continue with Luna Reserve
278
+ * Press enter to confirm or esc to continue working
279
+ *
280
+ * The SAFE key is **Escape** ("esc to continue working"), which dismisses
281
+ * the dialog and keeps the already-active Luna Reserve session. Escape is
282
+ * sent via sendSpecialKey, NEVER via pasteText (which would add an implicit
283
+ * Enter that could confirm option 1 = Reset usage).
284
+ *
285
+ * Design: keys: ["Escape"] + verifyAfterKeys: true (menu must vanish).
286
+ * No confirmBeforeEnter / keysAfterConfirm needed: Escape cannot select any
287
+ * numbered option — it is handled by sendSpecialKey, never by paste+Enter.
288
+ */
289
+ /** MUST match the exact hint row text to prevent false positives. */
290
+ const USAGE_LIMIT_ESC_HINT = /^\s*Press enter to confirm or esc to continue working\s*$/i;
291
+ /** Option-set that appears in this specific menu (exact wording). */
292
+ const USAGE_LIMIT_OPT1 = /^\s*[›❯>]?\s*1\.\s+Reset usage\b/i;
293
+ const USAGE_LIMIT_OPT3 = /^\s*[›❯>]?\s*3\.\s+Continue with Luna Reserve\b/i;
294
+ /**
295
+ * True when the usage-limit selection menu is the **current** interactive
296
+ * region of the pane (not a historical transcript copy).
297
+ *
298
+ * Structural requirements (all must hold):
299
+ * - Hint row "Press enter to confirm or esc to continue working"
300
+ * - Options 1 and 3 present in the bottom region (with exact text)
301
+ * - NOT followed by the Codex idle compositor (Context footer / Ask Codex row)
302
+ */
303
+ function codexUsageLimitMenuVisible(pane) {
304
+ const rows = pane.replace(/\r/g, "").split("\n");
305
+ let last = rows.length - 1;
306
+ while (last >= 0 && rows[last].trim() === "")
307
+ last--;
308
+ if (last < 0)
309
+ return false;
310
+ // The hint row "Press enter to confirm or esc to continue working" must be last.
311
+ if (!USAGE_LIMIT_ESC_HINT.test(rows[last]))
312
+ return false;
313
+ // The three options must appear in the bottom region (within 10 rows of hint).
314
+ const region = rows.slice(Math.max(0, last - 10), last);
315
+ const hasOpt1 = region.some(r => USAGE_LIMIT_OPT1.test(r));
316
+ const hasOpt3 = region.some(r => USAGE_LIMIT_OPT3.test(r));
317
+ if (!hasOpt1 || !hasOpt3)
318
+ return false;
319
+ // Guard: if the Codex compositor (idle prompt + Context footer) is at the
320
+ // bottom, this is scrollback, not the live menu.
321
+ const tail = region.join("\n");
322
+ if (/[›>]\s*Ask Codex to do anything\b/i.test(tail))
323
+ return false;
324
+ if (isCodexContextFooter(rows[last]))
325
+ return false;
326
+ return true;
327
+ }
328
+ /** Rate-switch prompts are economic choices, not a fixed keyboard position. */
329
+ function codexRateSwitchVisible(pane) {
330
+ const rows = pane.replace(/\r/g, "").split("\n");
331
+ let last = rows.length - 1;
332
+ while (last >= 0 && rows[last].trim() === "")
333
+ last--;
334
+ let title = -1;
335
+ for (let i = last; i >= Math.max(0, last - 18); i--) {
336
+ if (/^\s*(?:Approaching rate limits|Switch to .{1,120} for lower credit usage\?)\s*$/i.test(rows[i])) {
337
+ title = i;
338
+ break;
339
+ }
340
+ }
341
+ if (title < 0)
342
+ return false;
343
+ const tail = rows.slice(title + 1, last + 1);
344
+ // A copied picker in transcript history is not the currently active menu.
345
+ if (tail.some(row => /^[›>]\s+Ask Codex to do anything\b/.test(row) || isCodexContextFooter(row)))
346
+ return false;
347
+ return tail.some(row => /^\s*[›❯>]?\s*\d+\.\s*(?:Switch to|Keep current model)\b/i.test(row));
348
+ }
349
+ /** Unknown pickers own stdin too; never type or press Enter into one. */
350
+ function codexUnknownSelectionVisible(pane) {
351
+ const rows = pane.replace(/\r/g, "").split("\n");
352
+ let last = rows.length - 1;
353
+ while (last >= 0 && rows[last].trim() === "")
354
+ last--;
355
+ if (last < 0 || !(/\benter\b.*\besc\b/i.test(rows[last])
356
+ || /^\s*Press enter to continue\s*$/i.test(rows[last])))
357
+ return false;
358
+ let selected = -1;
359
+ for (let i = last - 1; i >= Math.max(0, last - 24); i--) {
360
+ if (/^\s*[›❯>]\s+\S/.test(rows[i])) {
361
+ selected = i;
362
+ break;
363
+ }
364
+ }
365
+ if (selected < 0)
366
+ return false;
367
+ if (/^\s*[›❯>]\s+Ask Codex to do anything\b/.test(rows[selected]))
368
+ return false;
369
+ return !rows.slice(selected + 1, last + 1).some(row => /^[›>]\s+Ask Codex to do anything\b/.test(row) || isCodexContextFooter(row));
370
+ }
371
+ /** Only the complete, current Codex installer picker may receive Escape. */
372
+ function codexUpdatePickerVisible(pane) {
373
+ const rows = pane.replace(/\r/g, "").split("\n");
374
+ let last = rows.length - 1;
375
+ while (last >= 0 && rows[last].trim() === "")
376
+ last--;
377
+ if (last < 0 || !/^\s*Press enter to continue\s*$/.test(rows[last]))
378
+ return false;
379
+ for (let first = last - 3; first >= Math.max(0, last - 9); first--) {
380
+ if (!/^\s*[›❯>]\s+1\.\s+Update now\b/.test(rows[first]))
381
+ continue;
382
+ // At 80 columns Codex 0.153 wraps the installer command to the next row.
383
+ // Permit a bounded continuation, but never another option or cursor.
384
+ const second = rows.findIndex((row, index) => index > first && index <= first + 3
385
+ && /^\s*2\.\s+Skip\s*$/.test(row));
386
+ if (second < 0 || !rows.slice(first + 1, second).every(row => /^\s+\S/.test(row) && !/^\s*[›❯>]?\s*\d+\./.test(row))
387
+ || !/^\s*3\.\s+Skip until next version\s*$/.test(rows[second + 1] ?? "")
388
+ || !rows.slice(second + 2, last).every(row => row.trim() === ""))
389
+ continue;
390
+ const intro = rows.slice(Math.max(0, first - 14), first);
391
+ if (intro.some(row => /Update available!/.test(row))
392
+ && intro.some(row => /^\s*Release notes: https:\/\/github\.com\/openai\/codex\/releases\/latest\s*$/.test(row)))
393
+ return true;
394
+ }
395
+ return false;
396
+ }
65
397
  function renderMcpServer(name, entry, instanceName) {
66
398
  const mcpName = `${name}-${instanceName}`.replace(/[^A-Za-z0-9_-]/g, "_");
67
399
  const env = { ...entry.env, AGEND_INSTANCE_NAME: instanceName };
@@ -117,11 +449,109 @@ export class CodexBackend {
117
449
  isolatedCodexHome;
118
450
  /** Which subscription this instance runs on, or null for the shared login. */
119
451
  credentialProfile = null;
452
+ /** Set only after preTrust wrote and read back this instance's private config. */
453
+ authorizedTrust = null;
120
454
  constructor(instanceDir) {
121
455
  this.instanceDir = instanceDir;
122
456
  this.binaryPath = resolveBinary("codex");
123
457
  this.sharedCodexHome = resolve(process.env.CODEX_HOME?.trim() || join(homedir(), ".codex"));
124
- this.isolatedCodexHome = resolve(instanceDir, "codex-home");
458
+ this.isolatedCodexHome = CodexBackend.resolveShortHome(instanceDir);
459
+ }
460
+ /**
461
+ * Compute and (on first call for an existing instance) migrate to a SHORT
462
+ * persistent CODEX_HOME under `~/.agend/cx/<8-char-hash>/`.
463
+ *
464
+ * Background (#953): codex 0.157.0 added `app-server-control.sock` under
465
+ * CODEX_HOME. For instances with long `-t<topic_id>` suffixes the full path
466
+ * exceeds the Unix socket SUN_LEN (~107 chars). codex canonicalises the path
467
+ * before binding the socket, so a symlink workaround does not help — the real
468
+ * path must be short.
469
+ *
470
+ * Migration steps (each is idempotent; half-completed states self-heal):
471
+ * 1. If shortHome does not exist AND legacyHome is a real directory (not a
472
+ * symlink) → atomic renameSync. On EXDEV or any other error, fall back
473
+ * safely: keep using the legacy long path (instance can still start, just
474
+ * without the short-path fix).
475
+ * 2. If shortHome exists but legacyHome is missing → recreate the backward-
476
+ * compat symlink (self-heal for rename-succeeded-but-symlink-failed crash).
477
+ * 3. If both shortHome and legacyHome (as symlink) exist → nothing to do.
478
+ * 4. New instance (no legacyHome) → create shortHome directly.
479
+ *
480
+ * Fail-safe guarantee: migration failure must never make the instance worse.
481
+ * The caller always receives a valid path it can use as CODEX_HOME.
482
+ */
483
+ /** Exposed for fleet-manager to delete the short home on instance removal. */
484
+ static shortHomeFor(instanceDir) {
485
+ // Resolve to canonical form before hashing so trailing slashes or
486
+ // non-canonical paths don't produce a different (orphaned) home.
487
+ const canonical = resolve(instanceDir);
488
+ const hash = createHash("sha256").update(canonical).digest("hex").slice(0, 8);
489
+ return join(getAgendHome(), "cx", hash);
490
+ }
491
+ static resolveShortHome(instanceDir) {
492
+ const shortHome = CodexBackend.shortHomeFor(instanceDir);
493
+ const shortBase = join(getAgendHome(), "cx");
494
+ const canonical = resolve(instanceDir);
495
+ const legacyHome = resolve(instanceDir, "codex-home");
496
+ // Only migrate/create when the instance directory itself exists.
497
+ // Constructing CodexBackend for a non-existent dir (cli-env probes, test
498
+ // backends that never ran) must not litter ~/.agend/cx/ with orphan dirs.
499
+ if (!existsSync(canonical))
500
+ return shortHome;
501
+ const legacyExists = existsSync(legacyHome);
502
+ const legacyIsRealDir = legacyExists && !lstatSync(legacyHome).isSymbolicLink();
503
+ const shortExists = existsSync(shortHome);
504
+ if (!shortExists) {
505
+ mkdirSync(shortBase, { recursive: true });
506
+ if (legacyIsRealDir) {
507
+ // Attempt atomic rename. Requires same device; fails with EXDEV otherwise.
508
+ try {
509
+ renameSync(legacyHome, shortHome);
510
+ // Log the one-time migration for observability.
511
+ try {
512
+ const logPath = join(shortBase, `${shortHome.split("/").at(-1)}.migrated`);
513
+ writeFileSync(logPath, `${new Date().toISOString()} migrated from ${legacyHome}\n`);
514
+ }
515
+ catch { /* log failure is non-fatal */ }
516
+ }
517
+ catch (err) {
518
+ // EXDEV (cross-device) or any other rename failure → fail-safe:
519
+ // fall back to the legacy long path so the instance still works.
520
+ // The socket-length bug persists for this instance but no data is lost.
521
+ const code = err.code ?? "unknown";
522
+ try {
523
+ const warnPath = join(shortBase, `${shortHome.split("/").at(-1)}.migration-failed`);
524
+ writeFileSync(warnPath, `${new Date().toISOString()} rename failed (${code}): ${err.message}\n`
525
+ + `legacy path remains in use: ${legacyHome}\n`);
526
+ }
527
+ catch { /* warn log failure is also non-fatal */ }
528
+ // Return legacy long path so the instance starts (socket may still fail
529
+ // on 0.157.0 for long names, but no data is lost or corrupted).
530
+ return legacyHome;
531
+ }
532
+ }
533
+ else if (!legacyExists) {
534
+ // New instance — no data to migrate.
535
+ mkdirSync(shortHome, { recursive: true, mode: 0o700 });
536
+ }
537
+ // If legacyExists but is already a symlink: shortHome was deleted externally
538
+ // while the symlink still points to it. Re-create shortHome as a fresh dir.
539
+ if (!existsSync(shortHome)) {
540
+ mkdirSync(shortHome, { recursive: true, mode: 0o700 });
541
+ }
542
+ }
543
+ // Backward-compat symlink: instanceDir/codex-home → shortHome.
544
+ // Also serves as self-heal: if a previous run renamed successfully but
545
+ // crashed before creating the symlink, we recreate it here.
546
+ if (!legacyExists || legacyIsRealDir) {
547
+ // legacyIsRealDir means rename just happened (now shortHome exists, legacyHome gone).
548
+ // !legacyExists means new instance or symlink was deleted — create it.
549
+ try {
550
+ symlinkSync(shortHome, legacyHome);
551
+ }
552
+ catch { /* race or already exists */ }
553
+ }
554
+ return shortHome;
125
555
  }
126
556
  supportsQueuedInput() {
127
557
  return true;
@@ -145,6 +575,29 @@ export class CodexBackend {
145
575
  getBottomReadyPattern() {
146
576
  return /^\s*›\s?/;
147
577
  }
578
+ /** Observed on real Codex 0.156.0: the live input row precedes its footer. */
579
+ isDeliveryInputReadyPane(pane) {
580
+ const rows = pane.replace(/\r/g, "").split("\n");
581
+ while (rows.length && !rows[rows.length - 1].trim())
582
+ rows.pop();
583
+ const footer = rows.pop() ?? "";
584
+ // Codex preserves other configured status-line items after the context
585
+ // meter (observed on 0.156.0: "Context 100% left · GPT-6-Astra"). They
586
+ // are footer chrome, not evidence that the input row is unavailable.
587
+ if (!isCodexContextFooter(footer))
588
+ return false;
589
+ // Pasted text may wrap over several continuation rows before the footer.
590
+ // Search only its immediate tail, not a historical transcript prompt.
591
+ for (let i = rows.length - 1; i >= Math.max(0, rows.length - 8); i--) {
592
+ if (/^[>›]\s+\d+\./.test(rows[i]))
593
+ return false;
594
+ if (/^[>›]\s+\S/.test(rows[i]))
595
+ return true;
596
+ if (/^[•■⚠]/.test(rows[i]))
597
+ return false;
598
+ }
599
+ return false;
600
+ }
148
601
  /** Live status chrome that must veto the broad prompt/context ready match. */
149
602
  getBusyPattern() {
150
603
  return /(?:^|\n)•\s+Working\b[^\n]*\besc to interrupt\b/i;
@@ -178,7 +631,7 @@ export class CodexBackend {
178
631
  return false;
179
632
  let footer = -1;
180
633
  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])) {
634
+ if (isCodexContextFooter(rows[i])) {
182
635
  footer = i;
183
636
  break;
184
637
  }
@@ -232,13 +685,17 @@ export class CodexBackend {
232
685
  // AgEnD instances are unattended processes: an interactive self-update
233
686
  // picker blocks delivery and must never be enabled by a copied global or
234
687
  // managed config layer. Keep this last so the CLI override is authoritative.
235
- cmd += " -c check_for_update_on_startup=false";
688
+ // Both observed Codex 0.155.0 and 0.156.1 support this launch flag. Pin
689
+ // the initial layout so a user/global fullscreen preference cannot make a
690
+ // header-only pane look ready. A later unknown layout still fails closed.
691
+ cmd += " -c check_for_update_on_startup=false --no-alt-screen";
236
692
  // CODEX_HOME is the only Codex-supported way to isolate the complete base
237
693
  // config. A profile only layers over the shared config and would therefore
238
694
  // still load every globally registered AgEnD MCP server.
239
695
  return `CODEX_HOME=${shellQuote(this.isolatedCodexHome)} ${cmd}`;
240
696
  }
241
697
  writeConfig(config) {
698
+ this.authorizedTrust = null;
242
699
  // Set before the home is prepared: which login this instance gets is a
243
700
  // property of the home, and the home is built here.
244
701
  this.credentialProfile = this.readProfile(config);
@@ -342,11 +799,15 @@ export class CodexBackend {
342
799
  content = readFileSync(configPath, "utf-8");
343
800
  }
344
801
  catch { /* no file yet */ }
802
+ // TOML allows quoted keys: `"status_line" = [...]` and `['tui']` are both
803
+ // legal. The regexes below accept an optional surrounding quote pair so that
804
+ // users who write their config with quoted keys still get context-remaining
805
+ // injected correctly. Both single and double TOML quotes are accepted.
345
806
  // Rule 1: any existing context item → don't touch anything.
346
- if (/status_line\s*=\s*\[[^\]]*context-(remaining|usage|used)[^\]]*\]/.test(content))
807
+ if (/["']?status_line["']?\s*=\s*\[[^\]]*context-(remaining|usage|used)[^\]]*\]/.test(content))
347
808
  return;
348
809
  const ITEM = "context-remaining";
349
- const arr = content.match(/status_line\s*=\s*\[([^\]]*)\]/);
810
+ const arr = content.match(/["']?status_line["']?\s*=\s*\[([^\]]*)\]/);
350
811
  if (arr) {
351
812
  // Rule 2b: prepend our item to the user's existing array (don't overwrite).
352
813
  // First position keeps "Context N% left" at the far left of the footer so a
@@ -359,8 +820,9 @@ export class CodexBackend {
359
820
  // Rule 2a: no status_line at all → add a minimal one.
360
821
  if (content.length && !content.endsWith("\n"))
361
822
  content += "\n";
362
- if (/^\[tui\]/m.test(content)) {
363
- content = content.replace(/^\[tui\][^\n]*\n/m, h => `${h}status_line = ["${ITEM}"]\n`);
823
+ // Also recognise quoted section headers: `["tui"]` and `['tui']`.
824
+ if (/^\[["']?tui["']?\]/m.test(content)) {
825
+ content = content.replace(/^\[["']?tui["']?\][^\n]*\n/m, h => `${h}status_line = ["${ITEM}"]\n`);
364
826
  }
365
827
  else {
366
828
  content += `\n[tui]\nstatus_line = ["${ITEM}"]\n`;
@@ -401,16 +863,23 @@ export class CodexBackend {
401
863
  return home;
402
864
  }
403
865
  preTrust(workDir) {
866
+ this.authorizedTrust = null;
867
+ const paths = codexTrustPaths(workDir);
404
868
  const configPath = join(this.isolatedCodexHome, "config.toml");
405
869
  let content = "";
406
870
  try {
407
871
  content = readFileSync(configPath, "utf-8");
408
872
  }
409
873
  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`);
874
+ const updated = setProjectTrusted(content, paths.root);
875
+ if (updated !== content)
876
+ atomicWritePrivate(configPath, updated);
877
+ // Do not authorize an automatic Enter merely because the write returned:
878
+ // a stale/untrusted section in the effective isolated config must fail shut.
879
+ const onDisk = readFileSync(configPath, "utf-8");
880
+ if (effectiveProjectTrust(onDisk, paths.root) !== "trusted")
881
+ throw new Error("Codex project trust did not persist");
882
+ this.authorizedTrust = paths;
414
883
  }
415
884
  /**
416
885
  * Preserve Codex login/session/cache behavior while isolating config.toml.
@@ -654,11 +1123,20 @@ export class CodexBackend {
654
1123
  }
655
1124
  }
656
1125
  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;
1126
+ // Header and context text persist in inline scrollback and even while the
1127
+ // CLI is loading or a modal owns stdin. Require the live prompt followed
1128
+ // by the Context footer at the *end* of the capture. getBusyPattern still
1129
+ // vetoes a working turn whose empty composer remains visible. Unknown TUI
1130
+ // layouts cannot claim readiness by merely rendering the old header.
1131
+ // U+22C6 is Codex's observed cosmetic starfield; it can be drawn in the
1132
+ // prompt, between prompt/footer, and below the footer. A drafted composer
1133
+ // is also idle once this same bottom footer proves it owns the screen.
1134
+ 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;
1135
+ }
1136
+ /** A proxy reply filters chrome per line; whole-pane readiness is separate. */
1137
+ isProxyReplyChromeLine(line) {
1138
+ return /^\s*[›>]\s+Ask Codex to do anything\s*$/.test(line)
1139
+ || isCodexContextFooter(line);
662
1140
  }
663
1141
  getErrorPatterns() {
664
1142
  return [
@@ -705,11 +1183,13 @@ export class CodexBackend {
705
1183
  // A capacity rejection is a completed failed turn: Codex returns to its
706
1184
  // prompt without an answer. Keep this anchored to the exact decorated
707
1185
  // TUI line so ordinary prose about model capacity cannot pause an
708
- // otherwise healthy instance. The CLI is already ready again, so there
709
- // is no recovery state to wait for after the pause notification.
1186
+ // otherwise healthy instance. The CLI is already back at the prompt, so
1187
+ // skipRecoveryWait avoids an extra wait before the backoff timer fires.
1188
+ // action "backoff_restart": exponential backoff + resume, up to 3 times;
1189
+ // the lifecycle falls back to "pause" after the limit (see #905).
710
1190
  pattern: /^⚠ Selected model is at capacity\. Please try a different model\.\r?$/m,
711
1191
  type: "model_error",
712
- action: "pause",
1192
+ action: "backoff_restart",
713
1193
  message: t("inst.codex_model_capacity"),
714
1194
  skipRecoveryWait: true,
715
1195
  },
@@ -756,12 +1236,40 @@ export class CodexBackend {
756
1236
  ];
757
1237
  }
758
1238
  getStartupDialogs() {
1239
+ const trustHold = this.trustHoldDialog();
759
1240
  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" },
1241
+ {
1242
+ pattern: /^\s*Trust this folder\?/m,
1243
+ keys: ["Enter"],
1244
+ description: "Codex authorized folder trust dialog",
1245
+ blocksDelivery: true,
1246
+ inputBlocked: true,
1247
+ autoResolutionKey: "codex-authorized-folder-trust",
1248
+ isActive: pane => {
1249
+ const state = codexTrustPromptState(pane);
1250
+ const authorized = this.authorizedTrust;
1251
+ return state.active && state.safeChoice && authorized !== null
1252
+ && state.folder === authorized.cwd
1253
+ && (state.rootNote === "absent" ? authorized.root === authorized.cwd
1254
+ : state.rootNote === "parsed" && state.root === authorized.root);
1255
+ },
1256
+ },
1257
+ trustHold,
762
1258
  this.updatePickerDialog(),
1259
+ this.unknownSelectionHoldDialog(),
763
1260
  ];
764
1261
  }
1262
+ trustHoldDialog() {
1263
+ return {
1264
+ pattern: /^\s*(?:Folder access|Trust this folder\?|Do you trust the files in this folder\?)/im,
1265
+ keys: [],
1266
+ description: "Codex folder trust needs human confirmation",
1267
+ holdOnly: true,
1268
+ blocksDelivery: true,
1269
+ inputBlocked: true,
1270
+ isActive: codexTrustVariantActive,
1271
+ };
1272
+ }
765
1273
  updatePickerDialog() {
766
1274
  return {
767
1275
  // Defense in depth for config written by older AgEnD versions or a Codex
@@ -777,26 +1285,70 @@ export class CodexBackend {
777
1285
  // seconds and a delivery can arrive first (hit live on codex-cli 0.153.4,
778
1286
  // which parks on this picker for as long as nobody answers it).
779
1287
  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);
1288
+ // Daemon.dialogMatches uses isActive INSTEAD OF pattern when present.
1289
+ // Check the complete current picker here; a different Enter-only menu
1290
+ // must never receive this automatic Escape key.
1291
+ isActive: codexUpdatePickerVisible,
1292
+ };
1293
+ }
1294
+ usageLimitLunaReserveDialog() {
1295
+ // The real codex usage-limit dialog states codex has ALREADY switched to
1296
+ // Luna Reserve and labels Escape as "continue working". Pressing Escape via
1297
+ // sendSpecialKey is the ONLY safe key:
1298
+ //
1299
+ // • Escape is sent via sendSpecialKey — never via pasteText (which adds
1300
+ // an implicit bracketed-paste Enter that could confirm Reset usage).
1301
+ // • A digit pasted via pasteText would leave the cursor on option 1 and
1302
+ // its own implicit Enter would fire Reset usage BEFORE any confirmation
1303
+ // gate. This path is explicitly NOT used.
1304
+ //
1305
+ // verifyAfterKeys: true — confirm the menu is gone after Escape.
1306
+ // No confirmBeforeEnter / keysAfterConfirm: Escape needs no confirm gate.
1307
+ return {
1308
+ pattern: /Press enter to confirm or esc to continue working/i,
1309
+ keys: ["Escape"],
1310
+ description: "Codex usage limit — pressing Escape to continue with Luna Reserve",
1311
+ blocksDelivery: true,
1312
+ inputBlocked: true,
1313
+ isActive: codexUsageLimitMenuVisible,
1314
+ verifyAfterKeys: true,
1315
+ autoResolutionKey: "codex-usage-limit-luna-reserve",
1316
+ postDismissNotice: {
1317
+ text: t("inst.codex_usage_limit_luna_reserve", ""), // instance name injected by daemon
1318
+ label: "codex-usage-limit-selected",
787
1319
  },
788
1320
  };
789
1321
  }
1322
+ unknownSelectionHoldDialog() {
1323
+ return {
1324
+ pattern: /^\s*[›❯>]\s+\S/m,
1325
+ keys: [],
1326
+ description: "Codex interactive selection needs human input",
1327
+ holdOnly: true,
1328
+ blocksDelivery: true,
1329
+ inputBlocked: true,
1330
+ isActive: codexUnknownSelectionVisible,
1331
+ };
1332
+ }
790
1333
  getRuntimeDialogs() {
791
1334
  return [
1335
+ this.trustHoldDialog(),
792
1336
  {
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",
1337
+ // Codex 0.156 may change the wording/order of this credit-cost choice.
1338
+ // Never navigate it by position: a moved option could switch to a
1339
+ // more expensive model. A live picker holds delivery and notifies a
1340
+ // human; old transcript mentions are excluded by the tail matcher.
1341
+ pattern: /(?:Approaching rate limits|Switch to [^\r\n]{1,120} for lower credit usage\?)/i,
1342
+ keys: [],
1343
+ description: "Codex rate limit model switch dialog needs human choice",
1344
+ holdOnly: true,
1345
+ blocksDelivery: true,
1346
+ inputBlocked: true,
1347
+ isActive: codexRateSwitchVisible,
798
1348
  },
799
1349
  this.updatePickerDialog(),
1350
+ this.usageLimitLunaReserveDialog(),
1351
+ this.unknownSelectionHoldDialog(),
800
1352
  ];
801
1353
  }
802
1354
  getInputUnavailableTransients() {