@songsid/agend 2.1.6-beta.12 → 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.
- package/README.md +22 -0
- package/dist/backend/codex-session.d.ts +45 -0
- package/dist/backend/codex-session.js +202 -0
- package/dist/backend/codex-session.js.map +1 -0
- package/dist/backend/codex.d.ts +48 -12
- package/dist/backend/codex.js +700 -65
- package/dist/backend/codex.js.map +1 -1
- package/dist/backend/kiro.d.ts +9 -0
- package/dist/backend/kiro.js +129 -0
- package/dist/backend/kiro.js.map +1 -1
- package/dist/backend/types.d.ts +25 -1
- package/dist/backend/types.js.map +1 -1
- package/dist/cli.js +23 -1
- package/dist/cli.js.map +1 -1
- package/dist/daemon.d.ts +41 -7
- package/dist/daemon.js +546 -91
- package/dist/daemon.js.map +1 -1
- package/dist/fleet-manager.d.ts +2 -0
- package/dist/fleet-manager.js +37 -0
- package/dist/fleet-manager.js.map +1 -1
- package/dist/instance-lifecycle.d.ts +7 -0
- package/dist/instance-lifecycle.js +126 -2
- package/dist/instance-lifecycle.js.map +1 -1
- package/dist/locale.js +6 -0
- package/dist/locale.js.map +1 -1
- package/dist/muse-usage-relay.js +14 -2
- package/dist/muse-usage-relay.js.map +1 -1
- package/dist/tmux-manager.d.ts +2 -0
- package/dist/tmux-manager.js +20 -0
- package/dist/tmux-manager.js.map +1 -1
- package/dist/usage/providers.js +10 -2
- package/dist/usage/providers.js.map +1 -1
- package/dist/usage/usage-api.d.ts +0 -12
- package/dist/usage/usage-api.js +24 -3
- package/dist/usage/usage-api.js.map +1 -1
- package/package.json +2 -1
package/dist/backend/codex.js
CHANGED
|
@@ -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
|
-
|
|
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 (
|
|
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
|
-
//
|
|
213
|
-
//
|
|
214
|
-
//
|
|
215
|
-
//
|
|
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 (
|
|
636
|
+
if (!this.resumeRecord) {
|
|
218
637
|
cmd = `${this.binaryPath} ${approvalFlag}`;
|
|
219
638
|
}
|
|
220
639
|
else {
|
|
221
|
-
cmd = `${this.binaryPath} resume
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
328
|
-
*
|
|
329
|
-
*
|
|
330
|
-
*
|
|
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
|
-
|
|
346
|
-
|
|
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
|
-
|
|
349
|
-
const
|
|
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
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
const
|
|
355
|
-
|
|
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 (
|
|
363
|
-
content = content.
|
|
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 +=
|
|
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
|
|
411
|
-
if (content
|
|
412
|
-
|
|
413
|
-
|
|
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
|
-
//
|
|
658
|
-
//
|
|
659
|
-
//
|
|
660
|
-
//
|
|
661
|
-
|
|
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
|
-
{
|
|
761
|
-
|
|
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
|
-
//
|
|
781
|
-
//
|
|
782
|
-
//
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
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
|
|
794
|
-
//
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
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
|
-
|
|
840
|
-
|
|
841
|
-
|
|
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"; }
|