@nervmor/codexui 1.0.3 → 1.0.5

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/dist-cli/index.js CHANGED
@@ -3,9 +3,9 @@
3
3
  // src/cli/index.ts
4
4
  import { createServer as createServer2 } from "http";
5
5
  import { chmodSync, createWriteStream, existsSync as existsSync3, mkdirSync } from "fs";
6
- import { readFile as readFile5 } from "fs/promises";
6
+ import { readFile as readFile5, stat as stat6, writeFile as writeFile5 } from "fs/promises";
7
7
  import { homedir as homedir4, networkInterfaces } from "os";
8
- import { join as join6 } from "path";
8
+ import { isAbsolute as isAbsolute3, join as join6, resolve as resolve2 } from "path";
9
9
  import { spawn as spawn4, spawnSync } from "child_process";
10
10
  import { createInterface } from "readline/promises";
11
11
  import { fileURLToPath as fileURLToPath2 } from "url";
@@ -58,6 +58,9 @@ function readNumber(value) {
58
58
  function readBoolean(value) {
59
59
  return typeof value === "boolean" ? value : null;
60
60
  }
61
+ function normalizeAccountUnavailableReason(value) {
62
+ return value === "payment_required" ? value : null;
63
+ }
61
64
  function setJson(res, statusCode, payload) {
62
65
  res.statusCode = statusCode;
63
66
  res.setHeader("Content-Type", "application/json; charset=utf-8");
@@ -77,6 +80,14 @@ function getErrorMessage(payload, fallback) {
77
80
  }
78
81
  return fallback;
79
82
  }
83
+ function isPaymentRequiredErrorMessage(value) {
84
+ if (!value) return false;
85
+ const normalized = value.toLowerCase();
86
+ return normalized.includes("payment required") || /\b402\b/.test(normalized);
87
+ }
88
+ function detectAccountUnavailableReason(error) {
89
+ return isPaymentRequiredErrorMessage(getErrorMessage(error, "")) ? "payment_required" : null;
90
+ }
80
91
  function getCodexHomeDir() {
81
92
  const codexHome = process.env.CODEX_HOME?.trim();
82
93
  return codexHome && codexHome.length > 0 ? codexHome : join(homedir(), ".codex");
@@ -159,7 +170,8 @@ function normalizeStoredAccountEntry(value) {
159
170
  quotaSnapshot: normalizeRateLimitSnapshot(record?.quotaSnapshot),
160
171
  quotaUpdatedAtIso: readString(record?.quotaUpdatedAtIso),
161
172
  quotaStatus,
162
- quotaError: readString(record?.quotaError)
173
+ quotaError: readString(record?.quotaError),
174
+ unavailableReason: normalizeAccountUnavailableReason(record?.unavailableReason) ?? (isPaymentRequiredErrorMessage(readString(record?.quotaError)) ? "payment_required" : null)
163
175
  };
164
176
  }
165
177
  async function readStoredAccountsState() {
@@ -248,6 +260,9 @@ async function writeSnapshot(storageId, raw) {
248
260
  await mkdir(dir, { recursive: true, mode: 448 });
249
261
  await writeFile(getSnapshotPath(storageId), raw, { encoding: "utf8", mode: 384 });
250
262
  }
263
+ async function removeSnapshot(storageId) {
264
+ await rm(join(getAccountsSnapshotRoot(), storageId), { recursive: true, force: true });
265
+ }
251
266
  async function readRuntimeAccountMetadata(appServer) {
252
267
  const payload = asRecord(await appServer.rpc("account/read", { refreshToken: false }));
253
268
  const account = asRecord(payload?.account);
@@ -345,8 +360,8 @@ async function withTemporaryCodexAppServer(authRaw, run) {
345
360
  };
346
361
  const call = async (method, params) => {
347
362
  const id = nextId++;
348
- return await new Promise((resolve2, reject) => {
349
- pending.set(id, { resolve: resolve2, reject });
363
+ return await new Promise((resolve3, reject) => {
364
+ pending.set(id, { resolve: resolve3, reject });
350
365
  sendLine({
351
366
  jsonrpc: "2.0",
352
367
  id,
@@ -435,6 +450,16 @@ async function replaceStoredAccount(nextEntry, activeAccountId) {
435
450
  accounts: nextState.accounts
436
451
  });
437
452
  }
453
+ async function pickReplacementActiveAccount(accounts) {
454
+ const sorted = sortAccounts(accounts, null);
455
+ for (const entry of sorted) {
456
+ if (entry.unavailableReason === "payment_required") continue;
457
+ if (await fileExists(getSnapshotPath(entry.storageId))) {
458
+ return entry;
459
+ }
460
+ }
461
+ return null;
462
+ }
438
463
  async function refreshAccountsInBackground(accountIds, activeAccountId) {
439
464
  for (const accountId of accountIds) {
440
465
  const state = await readStoredAccountsState();
@@ -449,14 +474,16 @@ async function refreshAccountsInBackground(accountIds, activeAccountId) {
449
474
  quotaSnapshot: inspected.quotaSnapshot ?? entry.quotaSnapshot,
450
475
  quotaUpdatedAtIso: (/* @__PURE__ */ new Date()).toISOString(),
451
476
  quotaStatus: "ready",
452
- quotaError: null
477
+ quotaError: null,
478
+ unavailableReason: null
453
479
  }, activeAccountId);
454
480
  } catch (error) {
455
481
  await replaceStoredAccount({
456
482
  ...entry,
457
483
  quotaUpdatedAtIso: (/* @__PURE__ */ new Date()).toISOString(),
458
484
  quotaStatus: "error",
459
- quotaError: getErrorMessage(error, "Failed to refresh account quota")
485
+ quotaError: getErrorMessage(error, "Failed to refresh account quota"),
486
+ unavailableReason: detectAccountUnavailableReason(error)
460
487
  }, activeAccountId);
461
488
  }
462
489
  }
@@ -509,7 +536,8 @@ async function importAccountFromAuthPath(path) {
509
536
  quotaSnapshot: existing?.quotaSnapshot ?? null,
510
537
  quotaUpdatedAtIso: existing?.quotaUpdatedAtIso ?? null,
511
538
  quotaStatus: existing?.quotaStatus ?? "idle",
512
- quotaError: existing?.quotaError ?? null
539
+ quotaError: existing?.quotaError ?? null,
540
+ unavailableReason: existing?.unavailableReason ?? null
513
541
  };
514
542
  const nextState = withUpsertedAccount(state, nextEntry);
515
543
  await writeStoredAccountsState(nextState);
@@ -559,7 +587,8 @@ async function handleAccountRoutes(req, res, url, context) {
559
587
  quotaSnapshot: inspection.quotaSnapshot ?? target.quotaSnapshot,
560
588
  quotaUpdatedAtIso: (/* @__PURE__ */ new Date()).toISOString(),
561
589
  quotaStatus: "ready",
562
- quotaError: null
590
+ quotaError: null,
591
+ unavailableReason: null
563
592
  };
564
593
  const nextState = withUpsertedAccount({
565
594
  activeAccountId: importedAccountId,
@@ -606,13 +635,13 @@ async function handleAccountRoutes(req, res, url, context) {
606
635
  });
607
636
  return true;
608
637
  }
609
- const rawBody = await new Promise((resolve2, reject) => {
638
+ const rawBody = await new Promise((resolve3, reject) => {
610
639
  let body = "";
611
640
  req.setEncoding("utf8");
612
641
  req.on("data", (chunk) => {
613
642
  body += chunk;
614
643
  });
615
- req.on("end", () => resolve2(body));
644
+ req.on("end", () => resolve3(body));
616
645
  req.on("error", reject);
617
646
  });
618
647
  const payload = asRecord(rawBody.length > 0 ? JSON.parse(rawBody) : {});
@@ -651,7 +680,8 @@ async function handleAccountRoutes(req, res, url, context) {
651
680
  quotaSnapshot: inspection.quotaSnapshot ?? target.quotaSnapshot,
652
681
  quotaUpdatedAtIso: (/* @__PURE__ */ new Date()).toISOString(),
653
682
  quotaStatus: "ready",
654
- quotaError: null
683
+ quotaError: null,
684
+ unavailableReason: null
655
685
  };
656
686
  const nextState = withUpsertedAccount({
657
687
  activeAccountId: accountId,
@@ -676,6 +706,13 @@ async function handleAccountRoutes(req, res, url, context) {
676
706
  } catch (error) {
677
707
  await restoreActiveAuth(previousRaw);
678
708
  appServer.dispose();
709
+ await replaceStoredAccount({
710
+ ...target,
711
+ quotaUpdatedAtIso: (/* @__PURE__ */ new Date()).toISOString(),
712
+ quotaStatus: "error",
713
+ quotaError: getErrorMessage(error, "Failed to switch account"),
714
+ unavailableReason: detectAccountUnavailableReason(error)
715
+ }, state.activeAccountId);
679
716
  setJson(res, 502, {
680
717
  error: "account_switch_failed",
681
718
  message: getErrorMessage(error, "Failed to switch account")
@@ -689,6 +726,145 @@ async function handleAccountRoutes(req, res, url, context) {
689
726
  }
690
727
  return true;
691
728
  }
729
+ if (req.method === "POST" && url.pathname === "/codex-api/accounts/remove") {
730
+ try {
731
+ const rawBody = await new Promise((resolve3, reject) => {
732
+ let body = "";
733
+ req.setEncoding("utf8");
734
+ req.on("data", (chunk) => {
735
+ body += chunk;
736
+ });
737
+ req.on("end", () => resolve3(body));
738
+ req.on("error", reject);
739
+ });
740
+ const payload = asRecord(rawBody.length > 0 ? JSON.parse(rawBody) : {});
741
+ const accountId = typeof payload?.accountId === "string" ? payload.accountId.trim() : "";
742
+ if (!accountId) {
743
+ setJson(res, 400, { error: "account_not_found", message: "Missing accountId." });
744
+ return true;
745
+ }
746
+ const state = await readStoredAccountsState();
747
+ const target = state.accounts.find((entry) => entry.accountId === accountId) ?? null;
748
+ if (!target) {
749
+ setJson(res, 404, { error: "account_not_found", message: "The requested account was not found." });
750
+ return true;
751
+ }
752
+ const remainingAccounts = state.accounts.filter((entry) => entry.accountId !== accountId);
753
+ if (state.activeAccountId !== accountId) {
754
+ await removeSnapshot(target.storageId);
755
+ await writeStoredAccountsState({
756
+ activeAccountId: state.activeAccountId,
757
+ accounts: remainingAccounts
758
+ });
759
+ setJson(res, 200, {
760
+ ok: true,
761
+ data: {
762
+ activeAccountId: state.activeAccountId,
763
+ accounts: sortAccounts(remainingAccounts, state.activeAccountId).map((entry) => toPublicAccountEntry(entry, state.activeAccountId))
764
+ }
765
+ });
766
+ return true;
767
+ }
768
+ if (appServer.listPendingServerRequests().length > 0) {
769
+ setJson(res, 409, {
770
+ error: "account_remove_blocked",
771
+ message: "Finish pending approval requests before removing the active account."
772
+ });
773
+ return true;
774
+ }
775
+ let previousRaw = null;
776
+ try {
777
+ previousRaw = await readFile(getActiveAuthPath(), "utf8");
778
+ } catch {
779
+ previousRaw = null;
780
+ }
781
+ const replacement = await pickReplacementActiveAccount(remainingAccounts);
782
+ if (!replacement) {
783
+ await restoreActiveAuth(null);
784
+ appServer.dispose();
785
+ await removeSnapshot(target.storageId);
786
+ await writeStoredAccountsState({
787
+ activeAccountId: null,
788
+ accounts: remainingAccounts
789
+ });
790
+ void scheduleAccountsBackgroundRefresh({
791
+ force: true,
792
+ accountIds: remainingAccounts.map((entry) => entry.accountId)
793
+ });
794
+ setJson(res, 200, {
795
+ ok: true,
796
+ data: {
797
+ activeAccountId: null,
798
+ accounts: sortAccounts(remainingAccounts, null).map((entry) => toPublicAccountEntry(entry, null))
799
+ }
800
+ });
801
+ return true;
802
+ }
803
+ const replacementSnapshotPath = getSnapshotPath(replacement.storageId);
804
+ if (!await fileExists(replacementSnapshotPath)) {
805
+ setJson(res, 404, {
806
+ error: "account_not_found",
807
+ message: "The replacement account snapshot is missing."
808
+ });
809
+ return true;
810
+ }
811
+ const replacementRaw = await readFile(replacementSnapshotPath, "utf8");
812
+ await writeFile(getActiveAuthPath(), replacementRaw, { encoding: "utf8", mode: 384 });
813
+ try {
814
+ appServer.dispose();
815
+ const inspection = await validateSwitchedAccount(appServer);
816
+ const activatedReplacement = {
817
+ ...replacement,
818
+ email: inspection.metadata.email ?? replacement.email,
819
+ planType: inspection.metadata.planType ?? replacement.planType,
820
+ lastActivatedAtIso: (/* @__PURE__ */ new Date()).toISOString(),
821
+ quotaSnapshot: inspection.quotaSnapshot ?? replacement.quotaSnapshot,
822
+ quotaUpdatedAtIso: (/* @__PURE__ */ new Date()).toISOString(),
823
+ quotaStatus: "ready",
824
+ quotaError: null,
825
+ unavailableReason: null
826
+ };
827
+ const nextAccounts = remainingAccounts.map((entry) => entry.accountId === activatedReplacement.accountId ? activatedReplacement : entry);
828
+ await removeSnapshot(target.storageId);
829
+ await writeStoredAccountsState({
830
+ activeAccountId: activatedReplacement.accountId,
831
+ accounts: nextAccounts
832
+ });
833
+ void scheduleAccountsBackgroundRefresh({
834
+ force: true,
835
+ prioritizeAccountId: activatedReplacement.accountId,
836
+ accountIds: nextAccounts.filter((entry) => entry.accountId !== activatedReplacement.accountId).map((entry) => entry.accountId)
837
+ });
838
+ setJson(res, 200, {
839
+ ok: true,
840
+ data: {
841
+ activeAccountId: activatedReplacement.accountId,
842
+ accounts: sortAccounts(nextAccounts, activatedReplacement.accountId).map((entry) => toPublicAccountEntry(entry, activatedReplacement.accountId))
843
+ }
844
+ });
845
+ } catch (error) {
846
+ await restoreActiveAuth(previousRaw);
847
+ appServer.dispose();
848
+ await replaceStoredAccount({
849
+ ...replacement,
850
+ quotaUpdatedAtIso: (/* @__PURE__ */ new Date()).toISOString(),
851
+ quotaStatus: "error",
852
+ quotaError: getErrorMessage(error, "Failed to switch account"),
853
+ unavailableReason: detectAccountUnavailableReason(error)
854
+ }, state.activeAccountId);
855
+ setJson(res, 502, {
856
+ error: "account_remove_failed",
857
+ message: getErrorMessage(error, "Failed to remove account")
858
+ });
859
+ }
860
+ } catch (error) {
861
+ setJson(res, 400, {
862
+ error: "invalid_auth_json",
863
+ message: getErrorMessage(error, "Failed to remove account")
864
+ });
865
+ }
866
+ return true;
867
+ }
692
868
  return false;
693
869
  }
694
870
 
@@ -729,7 +905,7 @@ function getSkillsInstallDir() {
729
905
  return join2(getCodexHomeDir2(), "skills");
730
906
  }
731
907
  async function runCommand(command, args, options = {}) {
732
- await new Promise((resolve2, reject) => {
908
+ await new Promise((resolve3, reject) => {
733
909
  const proc = spawn2(command, args, {
734
910
  cwd: options.cwd,
735
911
  env: process.env,
@@ -746,7 +922,7 @@ async function runCommand(command, args, options = {}) {
746
922
  proc.on("error", reject);
747
923
  proc.on("close", (code) => {
748
924
  if (code === 0) {
749
- resolve2();
925
+ resolve3();
750
926
  return;
751
927
  }
752
928
  const details = [stderr.trim(), stdout.trim()].filter(Boolean).join("\n");
@@ -756,7 +932,7 @@ async function runCommand(command, args, options = {}) {
756
932
  });
757
933
  }
758
934
  async function runCommandWithOutput(command, args, options = {}) {
759
- return await new Promise((resolve2, reject) => {
935
+ return await new Promise((resolve3, reject) => {
760
936
  const proc = spawn2(command, args, {
761
937
  cwd: options.cwd,
762
938
  env: process.env,
@@ -773,7 +949,7 @@ async function runCommandWithOutput(command, args, options = {}) {
773
949
  proc.on("error", reject);
774
950
  proc.on("close", (code) => {
775
951
  if (code === 0) {
776
- resolve2(stdout.trim());
952
+ resolve3(stdout.trim());
777
953
  return;
778
954
  }
779
955
  const details = [stderr.trim(), stdout.trim()].filter(Boolean).join("\n");
@@ -818,9 +994,9 @@ async function getGhToken() {
818
994
  proc.stdout.on("data", (d) => {
819
995
  out += d.toString();
820
996
  });
821
- return new Promise((resolve2) => {
822
- proc.on("close", (code) => resolve2(code === 0 ? out.trim() : null));
823
- proc.on("error", () => resolve2(null));
997
+ return new Promise((resolve3) => {
998
+ proc.on("close", (code) => resolve3(code === 0 ? out.trim() : null));
999
+ proc.on("error", () => resolve3(null));
824
1000
  });
825
1001
  } catch {
826
1002
  return null;
@@ -1059,7 +1235,7 @@ async function ensurePrivateForkFromUpstream(token, username, repoName) {
1059
1235
  ready = true;
1060
1236
  break;
1061
1237
  }
1062
- await new Promise((resolve2) => setTimeout(resolve2, 1e3));
1238
+ await new Promise((resolve3) => setTimeout(resolve3, 1e3));
1063
1239
  }
1064
1240
  if (!ready) throw new Error("Private mirror repo was created but is not available yet");
1065
1241
  if (!created) return;
@@ -1854,7 +2030,7 @@ function scoreFileCandidate(path, query) {
1854
2030
  return 10;
1855
2031
  }
1856
2032
  async function listFilesWithRipgrep(cwd) {
1857
- return await new Promise((resolve2, reject) => {
2033
+ return await new Promise((resolve3, reject) => {
1858
2034
  const proc = spawn3("rg", ["--files", "--hidden", "-g", "!.git", "-g", "!node_modules"], {
1859
2035
  cwd,
1860
2036
  env: process.env,
@@ -1872,7 +2048,7 @@ async function listFilesWithRipgrep(cwd) {
1872
2048
  proc.on("close", (code) => {
1873
2049
  if (code === 0) {
1874
2050
  const rows = stdout.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
1875
- resolve2(rows);
2051
+ resolve3(rows);
1876
2052
  return;
1877
2053
  }
1878
2054
  const details = [stderr.trim(), stdout.trim()].filter(Boolean).join("\n");
@@ -1885,7 +2061,7 @@ function getCodexHomeDir3() {
1885
2061
  return codexHome && codexHome.length > 0 ? codexHome : join3(homedir3(), ".codex");
1886
2062
  }
1887
2063
  async function runCommand2(command, args, options = {}) {
1888
- await new Promise((resolve2, reject) => {
2064
+ await new Promise((resolve3, reject) => {
1889
2065
  const proc = spawn3(command, args, {
1890
2066
  cwd: options.cwd,
1891
2067
  env: process.env,
@@ -1902,7 +2078,7 @@ async function runCommand2(command, args, options = {}) {
1902
2078
  proc.on("error", reject);
1903
2079
  proc.on("close", (code) => {
1904
2080
  if (code === 0) {
1905
- resolve2();
2081
+ resolve3();
1906
2082
  return;
1907
2083
  }
1908
2084
  const details = [stderr.trim(), stdout.trim()].filter(Boolean).join("\n");
@@ -1934,7 +2110,7 @@ async function ensureRepoHasInitialCommit(repoRoot) {
1934
2110
  );
1935
2111
  }
1936
2112
  async function runCommandCapture(command, args, options = {}) {
1937
- return await new Promise((resolve2, reject) => {
2113
+ return await new Promise((resolve3, reject) => {
1938
2114
  const proc = spawn3(command, args, {
1939
2115
  cwd: options.cwd,
1940
2116
  env: process.env,
@@ -1951,7 +2127,7 @@ async function runCommandCapture(command, args, options = {}) {
1951
2127
  proc.on("error", reject);
1952
2128
  proc.on("close", (code) => {
1953
2129
  if (code === 0) {
1954
- resolve2(stdout.trim());
2130
+ resolve3(stdout.trim());
1955
2131
  return;
1956
2132
  }
1957
2133
  const details = [stderr.trim(), stdout.trim()].filter(Boolean).join("\n");
@@ -1997,6 +2173,9 @@ async function readCodexAuth() {
1997
2173
  function getCodexGlobalStatePath() {
1998
2174
  return join3(getCodexHomeDir3(), ".codex-global-state.json");
1999
2175
  }
2176
+ function getCodexSessionIndexPath() {
2177
+ return join3(getCodexHomeDir3(), "session_index.jsonl");
2178
+ }
2000
2179
  var MAX_THREAD_TITLES = 500;
2001
2180
  function normalizeThreadTitleCache(value) {
2002
2181
  const record = asRecord3(value);
@@ -2024,6 +2203,47 @@ function removeFromThreadTitleCache(cache, id) {
2024
2203
  const { [id]: _, ...titles } = cache.titles;
2025
2204
  return { titles, order: cache.order.filter((o) => o !== id) };
2026
2205
  }
2206
+ function normalizeSessionIndexThreadTitle(value) {
2207
+ const record = asRecord3(value);
2208
+ if (!record) return null;
2209
+ const id = typeof record.id === "string" ? record.id.trim() : "";
2210
+ const title = typeof record.thread_name === "string" ? record.thread_name.trim() : "";
2211
+ const updatedAtIso = typeof record.updated_at === "string" ? record.updated_at.trim() : "";
2212
+ const updatedAtMs = updatedAtIso ? Date.parse(updatedAtIso) : Number.NaN;
2213
+ if (!id || !title) return null;
2214
+ return {
2215
+ id,
2216
+ title,
2217
+ updatedAtMs: Number.isFinite(updatedAtMs) ? updatedAtMs : 0
2218
+ };
2219
+ }
2220
+ function trimThreadTitleCache(cache) {
2221
+ const titles = { ...cache.titles };
2222
+ const order = cache.order.filter((id) => {
2223
+ if (!titles[id]) return false;
2224
+ return true;
2225
+ }).slice(0, MAX_THREAD_TITLES);
2226
+ for (const id of Object.keys(titles)) {
2227
+ if (!order.includes(id)) {
2228
+ delete titles[id];
2229
+ }
2230
+ }
2231
+ return { titles, order };
2232
+ }
2233
+ function mergeThreadTitleCaches(base, overlay) {
2234
+ const titles = { ...base.titles, ...overlay.titles };
2235
+ const order = [];
2236
+ for (const id of [...overlay.order, ...base.order]) {
2237
+ if (!titles[id] || order.includes(id)) continue;
2238
+ order.push(id);
2239
+ }
2240
+ for (const id of Object.keys(titles)) {
2241
+ if (!order.includes(id)) {
2242
+ order.push(id);
2243
+ }
2244
+ }
2245
+ return trimThreadTitleCache({ titles, order });
2246
+ }
2027
2247
  async function readThreadTitleCache() {
2028
2248
  const statePath = getCodexGlobalStatePath();
2029
2249
  try {
@@ -2046,6 +2266,42 @@ async function writeThreadTitleCache(cache) {
2046
2266
  payload["thread-titles"] = cache;
2047
2267
  await writeFile3(statePath, JSON.stringify(payload), "utf8");
2048
2268
  }
2269
+ async function readThreadTitlesFromSessionIndex() {
2270
+ try {
2271
+ const raw = await readFile3(getCodexSessionIndexPath(), "utf8");
2272
+ const latestById = /* @__PURE__ */ new Map();
2273
+ for (const line of raw.split(/\r?\n/u)) {
2274
+ const trimmed = line.trim();
2275
+ if (!trimmed) continue;
2276
+ try {
2277
+ const entry = normalizeSessionIndexThreadTitle(JSON.parse(trimmed));
2278
+ if (!entry) continue;
2279
+ const previous = latestById.get(entry.id);
2280
+ if (!previous || entry.updatedAtMs >= previous.updatedAtMs) {
2281
+ latestById.set(entry.id, entry);
2282
+ }
2283
+ } catch {
2284
+ }
2285
+ }
2286
+ const entries = Array.from(latestById.values()).sort((first, second) => second.updatedAtMs - first.updatedAtMs);
2287
+ const titles = {};
2288
+ const order = [];
2289
+ for (const entry of entries) {
2290
+ titles[entry.id] = entry.title;
2291
+ order.push(entry.id);
2292
+ }
2293
+ return trimThreadTitleCache({ titles, order });
2294
+ } catch {
2295
+ return { titles: {}, order: [] };
2296
+ }
2297
+ }
2298
+ async function readMergedThreadTitleCache() {
2299
+ const [sessionIndexCache, persistedCache] = await Promise.all([
2300
+ readThreadTitlesFromSessionIndex(),
2301
+ readThreadTitleCache()
2302
+ ]);
2303
+ return mergeThreadTitleCaches(sessionIndexCache, persistedCache);
2304
+ }
2049
2305
  async function readWorkspaceRootsState() {
2050
2306
  const statePath = getCodexGlobalStatePath();
2051
2307
  let payload = {};
@@ -2170,14 +2426,14 @@ async function proxyTranscribe(body, contentType, authToken, accountId) {
2170
2426
  if (accountId) {
2171
2427
  headers["ChatGPT-Account-Id"] = accountId;
2172
2428
  }
2173
- return new Promise((resolve2, reject) => {
2429
+ return new Promise((resolve3, reject) => {
2174
2430
  const req = httpsRequest(
2175
2431
  "https://chatgpt.com/backend-api/transcribe",
2176
2432
  { method: "POST", headers },
2177
2433
  (res) => {
2178
2434
  const chunks = [];
2179
2435
  res.on("data", (c) => chunks.push(c));
2180
- res.on("end", () => resolve2({ status: res.statusCode ?? 500, body: Buffer.concat(chunks).toString("utf8") }));
2436
+ res.on("end", () => resolve3({ status: res.statusCode ?? 500, body: Buffer.concat(chunks).toString("utf8") }));
2181
2437
  res.on("error", reject);
2182
2438
  }
2183
2439
  );
@@ -2334,8 +2590,8 @@ var AppServerProcess = class {
2334
2590
  async call(method, params) {
2335
2591
  this.start();
2336
2592
  const id = this.nextId++;
2337
- return new Promise((resolve2, reject) => {
2338
- this.pending.set(id, { resolve: resolve2, reject });
2593
+ return new Promise((resolve3, reject) => {
2594
+ this.pending.set(id, { resolve: resolve3, reject });
2339
2595
  this.sendLine({
2340
2596
  jsonrpc: "2.0",
2341
2597
  id,
@@ -2443,7 +2699,7 @@ var MethodCatalog = class {
2443
2699
  this.notificationCache = null;
2444
2700
  }
2445
2701
  async runGenerateSchemaCommand(outDir) {
2446
- await new Promise((resolve2, reject) => {
2702
+ await new Promise((resolve3, reject) => {
2447
2703
  const process2 = spawn3("codex", ["app-server", "generate-json-schema", "--out", outDir], {
2448
2704
  stdio: ["ignore", "ignore", "pipe"]
2449
2705
  });
@@ -2455,7 +2711,7 @@ var MethodCatalog = class {
2455
2711
  process2.on("error", reject);
2456
2712
  process2.on("exit", (code) => {
2457
2713
  if (code === 0) {
2458
- resolve2();
2714
+ resolve3();
2459
2715
  return;
2460
2716
  }
2461
2717
  reject(new Error(stderr.trim() || `generate-json-schema exited with code ${String(code)}`));
@@ -2881,7 +3137,7 @@ function createCodexBridgeMiddleware() {
2881
3137
  return;
2882
3138
  }
2883
3139
  if (req.method === "GET" && url.pathname === "/codex-api/thread-titles") {
2884
- const cache = await readThreadTitleCache();
3140
+ const cache = await readMergedThreadTitleCache();
2885
3141
  setJson3(res, 200, { data: cache });
2886
3142
  return;
2887
3143
  }
@@ -3245,9 +3501,9 @@ async function createDirectoryListingHtml(localPath) {
3245
3501
  const rows = items.map((item) => {
3246
3502
  const suffix = item.isDirectory ? "/" : "";
3247
3503
  const editAction = item.editable ? ` <a class="icon-btn" aria-label="Edit ${escapeHtml(item.name)}" href="${escapeHtml(toEditHref(item.path))}" title="Edit">\u270F\uFE0F</a>` : "";
3248
- return `<li class="file-row"><a class="file-link" href="${escapeHtml(toBrowseHref(item.path))}">${escapeHtml(item.name)}${suffix}</a>${editAction}</li>`;
3504
+ return `<li class="file-row"><a class="file-link" href="${escapeHtml(toBrowseHref(item.path))}">${escapeHtml(item.name)}${suffix}</a><span class="row-actions">${editAction}</span></li>`;
3249
3505
  }).join("\n");
3250
- const parentLink = localPath !== parentPath ? `<p><a href="${escapeHtml(toBrowseHref(parentPath))}">..</a></p>` : "";
3506
+ const parentLink = localPath !== parentPath ? `<a href="${escapeHtml(toBrowseHref(parentPath))}">..</a>` : "";
3251
3507
  return `<!doctype html>
3252
3508
  <html lang="en">
3253
3509
  <head>
@@ -3261,8 +3517,27 @@ async function createDirectoryListingHtml(localPath) {
3261
3517
  ul { list-style: none; padding: 0; margin: 12px 0 0; display: flex; flex-direction: column; gap: 8px; }
3262
3518
  .file-row { display: grid; grid-template-columns: minmax(0,1fr) auto; align-items: center; gap: 10px; }
3263
3519
  .file-link { display: block; padding: 10px 12px; border: 1px solid #28405f; border-radius: 10px; background: #0f1b33; overflow-wrap: anywhere; }
3264
- .icon-btn { display: inline-flex; align-items: center; justify-content: center; width: 42px; height: 42px; border: 1px solid #36557a; border-radius: 10px; background: #162643; text-decoration: none; }
3520
+ .header-actions { display: flex; align-items: center; gap: 10px; margin-top: 10px; flex-wrap: wrap; }
3521
+ .header-parent-link { color: #9ec8ff; font-size: 14px; padding: 8px 10px; border: 1px solid #2a4569; border-radius: 10px; background: #101f3a; }
3522
+ .header-parent-link:hover { text-decoration: none; filter: brightness(1.08); }
3523
+ .header-open-btn {
3524
+ height: 42px;
3525
+ padding: 0 14px;
3526
+ border: 1px solid #4f8de0;
3527
+ border-radius: 10px;
3528
+ background: linear-gradient(135deg, #2e6ee6 0%, #3d8cff 100%);
3529
+ color: #eef6ff;
3530
+ font-weight: 700;
3531
+ letter-spacing: 0.01em;
3532
+ cursor: pointer;
3533
+ box-shadow: 0 6px 18px rgba(33, 90, 199, 0.35);
3534
+ }
3535
+ .header-open-btn:hover { filter: brightness(1.08); }
3536
+ .header-open-btn:disabled { opacity: 0.6; cursor: default; }
3537
+ .row-actions { display: inline-flex; align-items: center; gap: 8px; min-width: 42px; justify-content: flex-end; }
3538
+ .icon-btn { display: inline-flex; align-items: center; justify-content: center; width: 42px; height: 42px; border: 1px solid #36557a; border-radius: 10px; background: #162643; color: #dbe6ff; text-decoration: none; cursor: pointer; }
3265
3539
  .icon-btn:hover { filter: brightness(1.08); text-decoration: none; }
3540
+ .status { margin: 10px 0 0; color: #8cc2ff; min-height: 1.25em; }
3266
3541
  h1 { font-size: 18px; margin: 0; word-break: break-all; }
3267
3542
  @media (max-width: 640px) {
3268
3543
  body { margin: 12px; }
@@ -3274,8 +3549,46 @@ async function createDirectoryListingHtml(localPath) {
3274
3549
  </head>
3275
3550
  <body>
3276
3551
  <h1>Index of ${escapeHtml(localPath)}</h1>
3277
- ${parentLink}
3552
+ <div class="header-actions">
3553
+ ${parentLink ? `<a class="header-parent-link" href="${escapeHtml(toBrowseHref(parentPath))}">..</a>` : ""}
3554
+ <button class="header-open-btn open-folder-btn" type="button" aria-label="Open current folder in Codex" title="Open folder in Codex" data-path="${escapeHtml(localPath)}">Open folder in Codex</button>
3555
+ </div>
3556
+ <p id="status" class="status"></p>
3278
3557
  <ul>${rows}</ul>
3558
+ <script>
3559
+ const status = document.getElementById('status');
3560
+ document.addEventListener('click', async (event) => {
3561
+ const target = event.target;
3562
+ if (!(target instanceof Element)) return;
3563
+ const button = target.closest('.open-folder-btn');
3564
+ if (!(button instanceof HTMLButtonElement)) return;
3565
+
3566
+ const path = button.getAttribute('data-path') || '';
3567
+ if (!path) return;
3568
+ button.disabled = true;
3569
+ status.textContent = 'Opening folder in Codex...';
3570
+ try {
3571
+ const response = await fetch('/codex-api/project-root', {
3572
+ method: 'POST',
3573
+ headers: { 'Content-Type': 'application/json' },
3574
+ body: JSON.stringify({
3575
+ path,
3576
+ createIfMissing: false,
3577
+ label: '',
3578
+ }),
3579
+ });
3580
+ if (!response.ok) {
3581
+ status.textContent = 'Failed to open folder.';
3582
+ button.disabled = false;
3583
+ return;
3584
+ }
3585
+ window.location.assign('/#/');
3586
+ } catch {
3587
+ status.textContent = 'Failed to open folder.';
3588
+ button.disabled = false;
3589
+ }
3590
+ });
3591
+ </script>
3279
3592
  </body>
3280
3593
  </html>`;
3281
3594
  }
@@ -3554,6 +3867,22 @@ function generatePassword() {
3554
3867
  // src/cli/index.ts
3555
3868
  var program = new Command().name("codexui").description("Web interface for Codex app-server");
3556
3869
  var __dirname2 = dirname3(fileURLToPath2(import.meta.url));
3870
+ var hasPromptedCloudflaredInstall = false;
3871
+ function getCodexHomePath() {
3872
+ return process.env.CODEX_HOME?.trim() || join6(homedir4(), ".codex");
3873
+ }
3874
+ function getCloudflaredPromptMarkerPath() {
3875
+ return join6(getCodexHomePath(), ".cloudflared-install-prompted");
3876
+ }
3877
+ function hasPromptedCloudflaredInstallPersisted() {
3878
+ return existsSync3(getCloudflaredPromptMarkerPath());
3879
+ }
3880
+ async function persistCloudflaredInstallPrompted() {
3881
+ const codexHome = getCodexHomePath();
3882
+ mkdirSync(codexHome, { recursive: true });
3883
+ await writeFile5(getCloudflaredPromptMarkerPath(), `${Date.now()}
3884
+ `, "utf8");
3885
+ }
3557
3886
  async function readCliVersion() {
3558
3887
  try {
3559
3888
  const packageJsonPath = join6(__dirname2, "..", "package.json");
@@ -3622,7 +3951,7 @@ function mapCloudflaredLinuxArch(arch) {
3622
3951
  return null;
3623
3952
  }
3624
3953
  function downloadFile(url, destination) {
3625
- return new Promise((resolve2, reject) => {
3954
+ return new Promise((resolve3, reject) => {
3626
3955
  const request = (currentUrl) => {
3627
3956
  httpsGet(currentUrl, (response) => {
3628
3957
  const code = response.statusCode ?? 0;
@@ -3640,7 +3969,7 @@ function downloadFile(url, destination) {
3640
3969
  response.pipe(file);
3641
3970
  file.on("finish", () => {
3642
3971
  file.close();
3643
- resolve2();
3972
+ resolve3();
3644
3973
  });
3645
3974
  file.on("error", reject);
3646
3975
  }).on("error", reject);
@@ -3676,6 +4005,14 @@ async function ensureCloudflaredInstalledLinux() {
3676
4005
  return installed;
3677
4006
  }
3678
4007
  async function shouldInstallCloudflaredInteractively() {
4008
+ if (hasPromptedCloudflaredInstall || hasPromptedCloudflaredInstallPersisted()) {
4009
+ return false;
4010
+ }
4011
+ hasPromptedCloudflaredInstall = true;
4012
+ await persistCloudflaredInstallPrompted();
4013
+ if (process.platform === "win32") {
4014
+ return false;
4015
+ }
3679
4016
  if (!process.stdin.isTTY || !process.stdout.isTTY) {
3680
4017
  console.warn("\n[cloudflared] cloudflared is missing and terminal is non-interactive, skipping install.");
3681
4018
  return false;
@@ -3694,6 +4031,9 @@ async function resolveCloudflaredForTunnel() {
3694
4031
  if (current) {
3695
4032
  return current;
3696
4033
  }
4034
+ if (process.platform === "win32") {
4035
+ return null;
4036
+ }
3697
4037
  const installApproved = await shouldInstallCloudflaredInteractively();
3698
4038
  if (!installApproved) {
3699
4039
  return null;
@@ -3701,7 +4041,7 @@ async function resolveCloudflaredForTunnel() {
3701
4041
  return ensureCloudflaredInstalledLinux();
3702
4042
  }
3703
4043
  function hasCodexAuth() {
3704
- const codexHome = process.env.CODEX_HOME?.trim() || join6(homedir4(), ".codex");
4044
+ const codexHome = getCodexHomePath();
3705
4045
  return existsSync3(join6(codexHome, "auth.json"));
3706
4046
  }
3707
4047
  function ensureCodexInstalled() {
@@ -3800,7 +4140,7 @@ function getAccessibleUrls(port) {
3800
4140
  return Array.from(urls);
3801
4141
  }
3802
4142
  async function startCloudflaredTunnel(command, localPort) {
3803
- return new Promise((resolve2, reject) => {
4143
+ return new Promise((resolve3, reject) => {
3804
4144
  const child = spawn4(command, ["tunnel", "--url", `http://localhost:${String(localPort)}`], {
3805
4145
  stdio: ["ignore", "pipe", "pipe"]
3806
4146
  });
@@ -3817,7 +4157,7 @@ async function startCloudflaredTunnel(command, localPort) {
3817
4157
  clearTimeout(timeout);
3818
4158
  child.stdout?.off("data", handleData);
3819
4159
  child.stderr?.off("data", handleData);
3820
- resolve2({ process: child, url: parsedUrl });
4160
+ resolve3({ process: child, url: parsedUrl });
3821
4161
  };
3822
4162
  const onError = (error) => {
3823
4163
  clearTimeout(timeout);
@@ -3836,7 +4176,7 @@ async function startCloudflaredTunnel(command, localPort) {
3836
4176
  });
3837
4177
  }
3838
4178
  function listenWithFallback(server, startPort) {
3839
- return new Promise((resolve2, reject) => {
4179
+ return new Promise((resolve3, reject) => {
3840
4180
  const attempt = (port) => {
3841
4181
  const onError = (error) => {
3842
4182
  server.off("listening", onListening);
@@ -3848,7 +4188,7 @@ function listenWithFallback(server, startPort) {
3848
4188
  };
3849
4189
  const onListening = () => {
3850
4190
  server.off("error", onError);
3851
- resolve2(port);
4191
+ resolve3(port);
3852
4192
  };
3853
4193
  server.once("error", onError);
3854
4194
  server.once("listening", onListening);
@@ -3857,8 +4197,72 @@ function listenWithFallback(server, startPort) {
3857
4197
  attempt(startPort);
3858
4198
  });
3859
4199
  }
4200
+ function getCodexGlobalStatePath2() {
4201
+ const codexHome = getCodexHomePath();
4202
+ return join6(codexHome, ".codex-global-state.json");
4203
+ }
4204
+ function normalizeUniqueStrings(value) {
4205
+ if (!Array.isArray(value)) return [];
4206
+ const next = [];
4207
+ for (const item of value) {
4208
+ if (typeof item !== "string") continue;
4209
+ const trimmed = item.trim();
4210
+ if (!trimmed || next.includes(trimmed)) continue;
4211
+ next.push(trimmed);
4212
+ }
4213
+ return next;
4214
+ }
4215
+ async function persistLaunchProject(projectPath) {
4216
+ const trimmed = projectPath.trim();
4217
+ if (!trimmed) return;
4218
+ const normalizedPath = isAbsolute3(trimmed) ? trimmed : resolve2(trimmed);
4219
+ const directoryInfo = await stat6(normalizedPath);
4220
+ if (!directoryInfo.isDirectory()) {
4221
+ throw new Error(`Not a directory: ${normalizedPath}`);
4222
+ }
4223
+ const statePath = getCodexGlobalStatePath2();
4224
+ let payload = {};
4225
+ try {
4226
+ const raw = await readFile5(statePath, "utf8");
4227
+ const parsed = JSON.parse(raw);
4228
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
4229
+ payload = parsed;
4230
+ }
4231
+ } catch {
4232
+ payload = {};
4233
+ }
4234
+ const roots = normalizeUniqueStrings(payload["electron-saved-workspace-roots"]);
4235
+ const activeRoots = normalizeUniqueStrings(payload["active-workspace-roots"]);
4236
+ payload["electron-saved-workspace-roots"] = [
4237
+ normalizedPath,
4238
+ ...roots.filter((value) => value !== normalizedPath)
4239
+ ];
4240
+ payload["active-workspace-roots"] = [
4241
+ normalizedPath,
4242
+ ...activeRoots.filter((value) => value !== normalizedPath)
4243
+ ];
4244
+ await writeFile5(statePath, JSON.stringify(payload), "utf8");
4245
+ }
4246
+ async function addProjectOnly(projectPath) {
4247
+ const trimmed = projectPath.trim();
4248
+ if (!trimmed) {
4249
+ throw new Error("Missing project path");
4250
+ }
4251
+ await persistLaunchProject(trimmed);
4252
+ }
3860
4253
  async function startServer(options) {
3861
4254
  const version = await readCliVersion();
4255
+ const projectPath = options.projectPath?.trim() ?? "";
4256
+ if (projectPath.length > 0) {
4257
+ try {
4258
+ await persistLaunchProject(projectPath);
4259
+ } catch (error) {
4260
+ const message = error instanceof Error ? error.message : String(error);
4261
+ console.warn(`
4262
+ [project] Could not open launch project: ${message}
4263
+ `);
4264
+ }
4265
+ }
3862
4266
  const codexCommand = ensureCodexInstalled() ?? resolveCodexCommand();
3863
4267
  if (!hasCodexAuth() && codexCommand) {
3864
4268
  console.log("\nCodex is not logged in. Starting `codex login`...\n");
@@ -3942,8 +4346,20 @@ async function runLogin() {
3942
4346
  console.log("\nStarting `codex login`...\n");
3943
4347
  runOrFail(codexCommand, ["login"], "Codex login");
3944
4348
  }
3945
- program.option("-p, --port <port>", "port to listen on", "5999").option("--password <pass>", "set a specific password").option("--no-password", "disable password protection").option("--tunnel", "start cloudflared tunnel", true).option("--no-tunnel", "disable cloudflared tunnel startup").action(async (opts) => {
3946
- await startServer(opts);
4349
+ program.argument("[projectPath]", "project directory to open on launch").option("--open-project <path>", "open project directory on launch (Codex desktop parity)").option("-p, --port <port>", "port to listen on", "5999").option("--password <pass>", "set a specific password").option("--no-password", "disable password protection").option("--tunnel", "start cloudflared tunnel", true).option("--no-tunnel", "disable cloudflared tunnel startup").action(async (projectPath, opts) => {
4350
+ const rawArgv = process.argv.slice(2);
4351
+ const openProjectFlagIndex = rawArgv.findIndex((arg) => arg === "--open-project" || arg.startsWith("--open-project="));
4352
+ let openProjectOnly = (opts.openProject ?? "").trim();
4353
+ if (!openProjectOnly && openProjectFlagIndex >= 0 && projectPath?.trim()) {
4354
+ openProjectOnly = projectPath.trim();
4355
+ }
4356
+ if (openProjectOnly.length > 0) {
4357
+ await addProjectOnly(openProjectOnly);
4358
+ console.log(`Added project: ${openProjectOnly}`);
4359
+ return;
4360
+ }
4361
+ const launchProject = (projectPath ?? "").trim();
4362
+ await startServer({ ...opts, projectPath: launchProject });
3947
4363
  });
3948
4364
  program.command("login").description("Install/check Codex CLI and run `codex login`").action(runLogin);
3949
4365
  program.command("help").description("Show codexui command help").action(() => {