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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -123,28 +123,6 @@ graph LR
123
123
  | Antigravity CLI | `curl -fsSL https://antigravity.google/cli/install.sh \| bash` | `agy` (Google Sign-In) |
124
124
  | Grok Build | `curl -fsSL https://x.ai/cli/install.sh \| bash` | `grok` (x.ai OAuth device flow) |
125
125
 
126
- ### Codex session upgrade note
127
-
128
- AgEnD now resumes Codex by an explicit, per-instance session ID. It never uses
129
- `codex resume --last`: that option can select another instance's conversation
130
- when worktrees or credential homes overlap. On the first restart after upgrading,
131
- an existing **live** Codex pane is linked to its exact open session when its
132
- rollout and writer lock can be verified. If a legacy pane was already stopped
133
- and has no instance-owned ID, the instance starts a new session and posts a
134
- one-time notice to its topic. Older rollout files remain on disk. A stored ID
135
- whose rollout cannot be verified is different: startup holds instead of
136
- silently opening a new conversation.
137
-
138
- To recover an older conversation, stop the instance, identify and verify its
139
- session UUID in your Codex session history, then run
140
- `agend fleet codex-resume <instance> <session-id>` and start the instance.
141
- The command refuses a different repository or a session with a live owner;
142
- it does not infer ownership from the newest session in a directory.
143
- If Codex keeps multiple rollout locks open after `/new`, AgEnD uses Codex's
144
- current-session footer to identify the new chat. Without that positive proof,
145
- automatic resume is held and the instance topic is notified; the existing
146
- conversation files remain available for an explicit manual attach.
147
-
148
126
  ## Requirements
149
127
 
150
128
  - Node.js >= 20
@@ -1,25 +1,6 @@
1
1
  import { type CliBackend, type CliBackendConfig, type ErrorPattern, type InputUnavailableTransient, type ModelOption, type RuntimeDialog, type StartupDialog } from "./types.js";
2
- type CodexResumeDirectoryPrompt = {
3
- active: boolean;
4
- sessionCwd: string | null;
5
- currentCwd: string | null;
6
- safeChoice: boolean;
7
- };
8
- /** Captured from Codex 0.156; only the live, bottom-of-pane four-choice menu. */
9
- export declare function codexResumeDirectoryPromptState(pane: string): CodexResumeDirectoryPrompt;
10
- /** Unknown variants still own stdin; only the exact canonical menu may be answered. */
11
- export declare function codexResumeDirectoryVisible(pane: string): boolean;
12
- /** Codex's concurrent-owner screen is a hold, never an invitation to press R. */
13
- export declare function codexResumeLockActive(pane: string): boolean;
14
- /** A changed lock-screen footer is still a hold, never a ready prompt. */
15
- export declare function codexResumeLockVisible(pane: string): boolean;
16
- /** macOS lockf and Linux flock both fail immediately with status 75 on contention. */
17
- export declare function codexResumeClaimCommand(platform: NodeJS.Platform, lockPath: string, launch: string): string;
18
- /** Explicit, stopped-instance recovery for an old conversation without an AgEnD owner record. */
19
- export declare function attachCodexSession(instanceDir: string, sharedHome: string, currentCwd: string, id: string): void;
20
2
  export declare class CodexBackend implements CliBackend {
21
3
  private instanceDir;
22
- private readonly procRoot;
23
4
  readonly binaryName = "codex";
24
5
  private binaryPath;
25
6
  private readonly sharedCodexHome;
@@ -28,10 +9,7 @@ export declare class CodexBackend implements CliBackend {
28
9
  private credentialProfile;
29
10
  /** Set only after preTrust wrote and read back this instance's private config. */
30
11
  private authorizedTrust;
31
- private activePanePid;
32
- private resumeRecord;
33
- private get unconfirmedSessionPath();
34
- constructor(instanceDir: string, procRoot?: string);
12
+ constructor(instanceDir: string);
35
13
  supportsQueuedInput(): boolean;
36
14
  /**
37
15
  * Codex's input row, from live captures on codex-cli 0.153.4: `› Ask Codex to
@@ -75,15 +53,6 @@ export declare class CodexBackend implements CliBackend {
75
53
  */
76
54
  getQueuedInputMarker(): RegExp | null;
77
55
  buildCommand(config: CliBackendConfig): string;
78
- setActivePanePid(pid: number | null): void;
79
- /** A fresh launch has no resume identity; it must not be counted as --resume. */
80
- canResume(workingDirectory: string): boolean;
81
- hasSessionIdentity(): boolean;
82
- hasInvalidSessionIdentity(workingDirectory: string): boolean;
83
- hasUnconfirmedSessionIdentity(): boolean;
84
- /** Positive owner evidence, not merely a stale lock-file name on disk. */
85
- resumeOwner(workingDirectory: string): number | null;
86
- private validResumeRecord;
87
56
  writeConfig(config: CliBackendConfig): void;
88
57
  /**
89
58
  * Stop Codex opening its "Update available!" picker when an instance starts.
@@ -103,10 +72,16 @@ export declare class CodexBackend implements CliBackend {
103
72
  */
104
73
  private disableStartupUpdateCheck;
105
74
  /**
106
- * The first status-line item is Codex's own current session ID. Unlike fd
107
- * order, this changes when /new switches chats while old writer locks stay
108
- * open. Keep context too, then preserve all user-selected remaining items.
109
- * If the footer is hidden/truncated, checkpointing fails closed instead.
75
+ * Ensure Codex's TUI status line shows context usage so /ctx can scrape it.
76
+ * Rules (never overwrites the user's status_line):
77
+ * 1. status_line already has a context item (context-remaining / -usage /
78
+ * -used) → leave the whole config untouched (they already show context).
79
+ * 2. no context item:
80
+ * - no status_line at all → write status_line = ["context-remaining"]
81
+ * - status_line exists → append "context-remaining" to it
82
+ * If a user's own status_line is long and truncates at 80 cols, that's their
83
+ * config — /ctx just reports context unavailable. Best-effort string edit of
84
+ * ~/.codex/config.toml (no toml dependency); other settings untouched.
110
85
  */
111
86
  private enableContextStatusLine;
112
87
  /** Null when the instance did not ask for a profile — today's behaviour. */
@@ -161,14 +136,12 @@ export declare class CodexBackend implements CliBackend {
161
136
  getErrorPatterns(): ErrorPattern[];
162
137
  getStartupDialogs(): StartupDialog[];
163
138
  private trustHoldDialog;
164
- private resumeDirectoryHoldDialog;
165
- private resumeLockHoldDialog;
166
139
  private updatePickerDialog;
167
140
  private unknownSelectionHoldDialog;
168
141
  getRuntimeDialogs(): RuntimeDialog[];
169
142
  getInputUnavailableTransients(): InputUnavailableTransient[];
170
143
  getContextUsage(): number | null;
171
- getSessionId(pane?: string): string | null;
144
+ getSessionId(): string | null;
172
145
  getQuitCommand(): string;
173
146
  getCompactCommand(): string;
174
147
  getClearCommand(): string;
@@ -230,4 +203,3 @@ export declare class CodexBackend implements CliBackend {
230
203
  }>;
231
204
  cleanup(config: CliBackendConfig): void;
232
205
  }
233
- export {};
@@ -9,13 +9,14 @@ import { getAgendHome } from "../paths.js";
9
9
  import { appendWithMarker, removeMarker } from "./marker-utils.js";
10
10
  import { t } from "../locale.js";
11
11
  import { parse as parseToml } from "smol-toml";
12
- import { CODEX_SESSION_ID, CodexResumeIdentityError, codexCurrentSessionFromPane, codexRolloutForId, codexSessionsForPane, codexSessionOwners, readCodexRolloutMeta } from "./codex-session.js";
13
12
  const CODEX_PROJECT_DOC_MAX_BYTES = 32_768;
14
13
  const CODEX_MODELS_CACHE_MAX_BYTES = 5 * 1024 * 1024;
15
14
  const SAFE_MODEL_ID_RE = /^[A-Za-z0-9._:/-]+$/;
16
- const AGEND_MCP_CLEANUP_LOCK = ".agend-mcp-cleanup.lock";
17
- const AGEND_MCP_CLEANUP_LOCK_STALE_MS = 30_000;
18
- const SQLITE_SIDECAR_RE = /-(?:wal|shm|journal)$/;
15
+ /**
16
+ * Whether a pane row is Codex's context footer. Kept when #913 was reverted:
17
+ * #913 introduced it, but #914's pane/ready detection is built on it, and it
18
+ * is pane parsing, not session handling.
19
+ */
19
20
  function isCodexContextFooter(row) {
20
21
  const context = String.raw `Context\s+\d+%\s+(?:left|used)`;
21
22
  const legacy = new RegExp(String.raw `^\s*${context}(?:\s+⚠\s+\d+\s+warnings?\b[^\r\n]*)?(?:\s+·\s+\S[^\r\n]*)?\s*$`, "i");
@@ -26,6 +27,9 @@ function isCodexContextFooter(row) {
26
27
  // /ctx honestly reports context unavailable from a truncated percentage.
27
28
  return /^\s*[0-9a-f-]{36}\s+·\s+Context\b[^\r\n]*$/i.test(row);
28
29
  }
30
+ const AGEND_MCP_CLEANUP_LOCK = ".agend-mcp-cleanup.lock";
31
+ const AGEND_MCP_CLEANUP_LOCK_STALE_MS = 30_000;
32
+ const SQLITE_SIDECAR_RE = /-(?:wal|shm|journal)$/;
29
33
  /**
30
34
  * Remove AgEnD-owned MCP tables from a Codex TOML config without touching
31
35
  * unrelated user settings or third-party MCP servers. Track TOML multiline
@@ -175,83 +179,6 @@ function setProjectTrusted(content, root) {
175
179
  throw new Error("Codex project trust is not effective");
176
180
  return updated;
177
181
  }
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
182
  /** Only the bottom, live Codex 0.156 folder-access screen can own stdin. */
256
183
  function codexTrustPromptState(pane) {
257
184
  const noPrompt = { active: false, folder: null, root: null, rootNote: "absent", safeChoice: false };
@@ -438,49 +365,6 @@ function atomicWritePrivate(path, content) {
438
365
  catch { }
439
366
  }
440
367
  }
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
- }
484
368
  // Account-aware models_cache.json is preferred. These documented Codex models
485
369
  // are only a last-resort menu when the TUI has not populated its cache yet.
486
370
  /** The whole of a codex identity, and the only file a profile owns. */
@@ -495,7 +379,6 @@ const CODEX_FALLBACK_MODELS = [
495
379
  ];
496
380
  export class CodexBackend {
497
381
  instanceDir;
498
- procRoot;
499
382
  binaryName = "codex";
500
383
  binaryPath;
501
384
  sharedCodexHome;
@@ -504,12 +387,8 @@ export class CodexBackend {
504
387
  credentialProfile = null;
505
388
  /** Set only after preTrust wrote and read back this instance's private config. */
506
389
  authorizedTrust = null;
507
- activePanePid = null;
508
- resumeRecord = null;
509
- get unconfirmedSessionPath() { return join(this.instanceDir, "codex-session-unconfirmed"); }
510
- constructor(instanceDir, procRoot = "/proc") {
390
+ constructor(instanceDir) {
511
391
  this.instanceDir = instanceDir;
512
- this.procRoot = procRoot;
513
392
  this.binaryPath = resolveBinary("codex");
514
393
  this.sharedCodexHome = resolve(process.env.CODEX_HOME?.trim() || join(homedir(), ".codex"));
515
394
  this.isolatedCodexHome = resolve(instanceDir, "codex-home");
@@ -542,9 +421,9 @@ export class CodexBackend {
542
421
  while (rows.length && !rows[rows.length - 1].trim())
543
422
  rows.pop();
544
423
  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.
424
+ // Codex preserves other configured status-line items after the context
425
+ // meter (observed on 0.156.0: "Context 100% left · GPT-6-Astra"). They
426
+ // are footer chrome, not evidence that the input row is unavailable.
548
427
  if (!isCodexContextFooter(footer))
549
428
  return false;
550
429
  // Pasted text may wrap over several continuation rows before the footer.
@@ -623,21 +502,16 @@ export class CodexBackend {
623
502
  const approvalFlag = config.skipPermissions !== false
624
503
  ? "--dangerously-bypass-approvals-and-sandbox"
625
504
  : "--full-auto";
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);
505
+ // `codex resume --last` resumes the most recent session for the current
506
+ // working directory. Each AgEnD instance has a unique working_directory,
507
+ // so sessions are per-instance scoped and won't collide.
508
+ // If no prior session exists (first launch), Codex falls back to a fresh session.
635
509
  let cmd;
636
- if (!this.resumeRecord) {
510
+ if (config.skipResume) {
637
511
  cmd = `${this.binaryPath} ${approvalFlag}`;
638
512
  }
639
513
  else {
640
- cmd = `${this.binaryPath} resume ${shellQuote(this.resumeRecord.id)} ${approvalFlag}`;
514
+ cmd = `${this.binaryPath} resume --last ${approvalFlag}`;
641
515
  }
642
516
  if (config.model) {
643
517
  const model = validateModel(config.model);
@@ -658,58 +532,7 @@ export class CodexBackend {
658
532
  // CODEX_HOME is the only Codex-supported way to isolate the complete base
659
533
  // config. A profile only layers over the shared config and would therefore
660
534
  // still load every globally registered AgEnD MCP server.
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
- }
535
+ return `CODEX_HOME=${shellQuote(this.isolatedCodexHome)} ${cmd}`;
713
536
  }
714
537
  writeConfig(config) {
715
538
  this.authorizedTrust = null;
@@ -798,10 +621,16 @@ export class CodexBackend {
798
621
  catch { /* best effort */ }
799
622
  }
800
623
  /**
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.
624
+ * Ensure Codex's TUI status line shows context usage so /ctx can scrape it.
625
+ * Rules (never overwrites the user's status_line):
626
+ * 1. status_line already has a context item (context-remaining / -usage /
627
+ * -used) → leave the whole config untouched (they already show context).
628
+ * 2. no context item:
629
+ * - no status_line at all → write status_line = ["context-remaining"]
630
+ * - status_line exists → append "context-remaining" to it
631
+ * If a user's own status_line is long and truncates at 80 cols, that's their
632
+ * config — /ctx just reports context unavailable. Best-effort string edit of
633
+ * ~/.codex/config.toml (no toml dependency); other settings untouched.
805
634
  */
806
635
  enableContextStatusLine() {
807
636
  const configPath = join(this.isolatedCodexHome, "config.toml");
@@ -810,48 +639,31 @@ export class CodexBackend {
810
639
  content = readFileSync(configPath, "utf-8");
811
640
  }
812
641
  catch { /* no file yet */ }
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 {
642
+ // Rule 1: any existing context item → don't touch anything.
643
+ if (/status_line\s*=\s*\[[^\]]*context-(remaining|usage|used)[^\]]*\]/.test(content))
824
644
  return;
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
645
+ const ITEM = "context-remaining";
646
+ const arr = content.match(/status_line\s*=\s*\[([^\]]*)\]/);
834
647
  if (arr) {
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);
648
+ // Rule 2b: prepend our item to the user's existing array (don't overwrite).
649
+ // First position keeps "Context N% left" at the far left of the footer so a
650
+ // long cwd/other items can't push it past 80 cols and truncate it.
651
+ const inner = arr[1].trim().replace(/^,\s*/, "").replace(/,\s*$/, "");
652
+ const newInner = inner.length ? `"${ITEM}", ${inner}` : `"${ITEM}"`;
653
+ content = content.replace(arr[0], `status_line = [${newInner}]`);
840
654
  }
841
655
  else {
656
+ // Rule 2a: no status_line at all → add a minimal one.
842
657
  if (content.length && !content.endsWith("\n"))
843
658
  content += "\n";
844
- if (tuiHeader) {
845
- content = content.slice(0, tuiStart) + '\nstatus_line = ["session-id", "context-remaining"]' + content.slice(tuiStart);
659
+ if (/^\[tui\]/m.test(content)) {
660
+ content = content.replace(/^\[tui\][^\n]*\n/m, h => `${h}status_line = ["${ITEM}"]\n`);
846
661
  }
847
662
  else {
848
- content += '\n[tui]\nstatus_line = ["session-id", "context-remaining"]\n';
663
+ content += `\n[tui]\nstatus_line = ["${ITEM}"]\n`;
849
664
  }
850
665
  }
851
666
  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);
855
667
  atomicWritePrivate(configPath, content);
856
668
  }
857
669
  catch { /* best effort — never block launch on statusline config */ }
@@ -1259,23 +1071,6 @@ export class CodexBackend {
1259
1071
  getStartupDialogs() {
1260
1072
  const trustHold = this.trustHoldDialog();
1261
1073
  return [
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
1074
  {
1280
1075
  pattern: /^\s*Trust this folder\?/m,
1281
1076
  keys: ["Enter"],
@@ -1308,28 +1103,6 @@ export class CodexBackend {
1308
1103
  isActive: codexTrustVariantActive,
1309
1104
  };
1310
1105
  }
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
- }
1333
1106
  updatePickerDialog() {
1334
1107
  return {
1335
1108
  // Defense in depth for config written by older AgEnD versions or a Codex
@@ -1365,8 +1138,6 @@ export class CodexBackend {
1365
1138
  getRuntimeDialogs() {
1366
1139
  return [
1367
1140
  this.trustHoldDialog(),
1368
- this.resumeDirectoryHoldDialog(),
1369
- this.resumeLockHoldDialog(),
1370
1141
  {
1371
1142
  // Codex 0.156 may change the wording/order of this credit-cost choice.
1372
1143
  // Never navigate it by position: a moved option could switch to a
@@ -1420,60 +1191,10 @@ export class CodexBackend {
1420
1191
  getContextUsage() {
1421
1192
  return null;
1422
1193
  }
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;
1194
+ getSessionId() {
1195
+ // Codex manages sessions internally via SQLite (~/.codex/state_5.sqlite).
1196
+ // `resume --last` handles session selection by CWD automatically.
1197
+ return null;
1477
1198
  }
1478
1199
  getQuitCommand() { return "/quit"; }
1479
1200
  getCompactCommand() { return "/compact"; }