failproofai 0.0.9-beta.1 → 0.0.9-beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (155) hide show
  1. package/.next/standalone/.next/BUILD_ID +1 -1
  2. package/.next/standalone/.next/build-manifest.json +6 -6
  3. package/.next/standalone/.next/prerender-manifest.json +3 -3
  4. package/.next/standalone/.next/required-server-files.json +1 -1
  5. package/.next/standalone/.next/server/app/_global-error/page/build-manifest.json +3 -3
  6. package/.next/standalone/.next/server/app/_global-error/page/server-reference-manifest.json +1 -1
  7. package/.next/standalone/.next/server/app/_global-error/page.js.nft.json +1 -1
  8. package/.next/standalone/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
  9. package/.next/standalone/.next/server/app/_global-error.html +1 -1
  10. package/.next/standalone/.next/server/app/_global-error.rsc +7 -7
  11. package/.next/standalone/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +2 -2
  12. package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +7 -7
  13. package/.next/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +3 -3
  14. package/.next/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +3 -3
  15. package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  16. package/.next/standalone/.next/server/app/_not-found/page/build-manifest.json +3 -3
  17. package/.next/standalone/.next/server/app/_not-found/page/server-reference-manifest.json +1 -1
  18. package/.next/standalone/.next/server/app/_not-found/page.js.nft.json +1 -1
  19. package/.next/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  20. package/.next/standalone/.next/server/app/_not-found.html +2 -2
  21. package/.next/standalone/.next/server/app/_not-found.rsc +15 -15
  22. package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +15 -15
  23. package/.next/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +4 -4
  24. package/.next/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +10 -10
  25. package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +2 -2
  26. package/.next/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +3 -3
  27. package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  28. package/.next/standalone/.next/server/app/api/download/[project]/[session]/route.js +1 -2
  29. package/.next/standalone/.next/server/app/api/download/[project]/[session]/route.js.nft.json +1 -1
  30. package/.next/standalone/.next/server/app/index.html +1 -1
  31. package/.next/standalone/.next/server/app/index.rsc +15 -15
  32. package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +2 -2
  33. package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +15 -15
  34. package/.next/standalone/.next/server/app/index.segments/_head.segment.rsc +4 -4
  35. package/.next/standalone/.next/server/app/index.segments/_index.segment.rsc +10 -10
  36. package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  37. package/.next/standalone/.next/server/app/page/build-manifest.json +3 -3
  38. package/.next/standalone/.next/server/app/page/server-reference-manifest.json +1 -1
  39. package/.next/standalone/.next/server/app/page.js.nft.json +1 -1
  40. package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
  41. package/.next/standalone/.next/server/app/policies/page/build-manifest.json +3 -3
  42. package/.next/standalone/.next/server/app/policies/page/server-reference-manifest.json +8 -8
  43. package/.next/standalone/.next/server/app/policies/page.js +2 -2
  44. package/.next/standalone/.next/server/app/policies/page.js.nft.json +1 -1
  45. package/.next/standalone/.next/server/app/policies/page_client-reference-manifest.js +1 -1
  46. package/.next/standalone/.next/server/app/project/[name]/page/build-manifest.json +3 -3
  47. package/.next/standalone/.next/server/app/project/[name]/page/server-reference-manifest.json +1 -1
  48. package/.next/standalone/.next/server/app/project/[name]/page.js +1 -1
  49. package/.next/standalone/.next/server/app/project/[name]/page.js.nft.json +1 -1
  50. package/.next/standalone/.next/server/app/project/[name]/page_client-reference-manifest.js +1 -1
  51. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/build-manifest.json +3 -3
  52. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/react-loadable-manifest.json +2 -2
  53. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/server-reference-manifest.json +2 -2
  54. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js +4 -3
  55. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js.nft.json +1 -1
  56. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page_client-reference-manifest.js +1 -1
  57. package/.next/standalone/.next/server/app/projects/page/build-manifest.json +3 -3
  58. package/.next/standalone/.next/server/app/projects/page/server-reference-manifest.json +1 -1
  59. package/.next/standalone/.next/server/app/projects/page.js +2 -2
  60. package/.next/standalone/.next/server/app/projects/page.js.nft.json +1 -1
  61. package/.next/standalone/.next/server/app/projects/page_client-reference-manifest.js +1 -1
  62. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0g72weg._.js +1 -1
  63. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0su~k6f._.js +3 -0
  64. package/.next/standalone/.next/server/chunks/lib_codex-projects_ts_07qqk1g._.js +3 -0
  65. package/.next/standalone/.next/server/chunks/package_json_[json]_cjs_0z7w.hh._.js +1 -1
  66. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__01743wx._.js +3 -0
  67. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__092s1ta._.js +2 -2
  68. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__09icjsf._.js +2 -2
  69. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0.f_cyx._.js → [root-of-the-server]__0eu4j_n._.js} +2 -2
  70. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0g.lg8b._.js +2 -2
  71. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0gs6wz4._.js +3 -0
  72. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0h..k-e._.js +2 -2
  73. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0it81ys._.js +3 -0
  74. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0u4a9jq._.js +4 -0
  75. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0dub28-._.js → [root-of-the-server]__0vrlxf2._.js} +2 -2
  76. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0ymlddl._.js +18 -0
  77. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__11pa2ra._.js +2 -2
  78. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__12.h2mg._.js +3 -0
  79. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__12t-wym._.js +2 -2
  80. package/.next/standalone/.next/server/chunks/ssr/_04w00cm._.js +3 -0
  81. package/.next/standalone/.next/server/chunks/ssr/_10lm7or._.js +2 -2
  82. package/.next/standalone/.next/server/chunks/ssr/app_0cdqd9w._.js +3 -0
  83. package/.next/standalone/.next/server/chunks/ssr/app_global-error_tsx_0xerkr6._.js +1 -1
  84. package/.next/standalone/.next/server/chunks/ssr/app_policies_hooks-client_tsx_0q-m0y-._.js +1 -1
  85. package/.next/standalone/.next/server/chunks/ssr/lib_codex-projects_ts_0eosib~._.js +3 -0
  86. package/.next/standalone/.next/server/chunks/ssr/lib_utils_ts_068jk73._.js +3 -0
  87. package/.next/standalone/.next/server/chunks/ssr/{_0zaq1hm._.js → node_modules_0ttbz1~._.js} +2 -2
  88. package/.next/standalone/.next/server/middleware-build-manifest.js +6 -6
  89. package/.next/standalone/.next/server/pages/404.html +2 -2
  90. package/.next/standalone/.next/server/pages/500.html +1 -1
  91. package/.next/standalone/.next/server/server-reference-manifest.js +1 -1
  92. package/.next/standalone/.next/server/server-reference-manifest.json +9 -9
  93. package/.next/standalone/.next/static/chunks/{0_c_yox08g_44.js → 01q52wg_amm60.js} +2 -2
  94. package/.next/standalone/.next/static/chunks/03egp37o1l629.js +1 -0
  95. package/.next/standalone/.next/static/chunks/06j6c0ofqjy0v.js +6 -0
  96. package/.next/standalone/.next/static/chunks/06x4-d1~o-opr.js +1 -0
  97. package/.next/standalone/.next/static/chunks/{0jryicwtm9z2g.js → 0e8c_1f7-8e7t.js} +3 -3
  98. package/.next/standalone/.next/static/chunks/0mumk7h5i.1xd.js +1 -0
  99. package/.next/standalone/.next/static/chunks/0n~s0gafwnp2y.js +1 -0
  100. package/.next/standalone/.next/static/chunks/{0bghqwo4iloy0.js → 0zdn~84f58hvf.js} +1 -1
  101. package/.next/standalone/.next/static/chunks/0zig0fh30t6ou.js +1 -0
  102. package/.next/standalone/.next/static/chunks/{0z-jh701rc~j8.js → 10mlwc4y_kqo2.js} +1 -1
  103. package/.next/standalone/.next/static/chunks/{0kzk5-mh1_x53.js → 12simlrcfk3g2.js} +1 -1
  104. package/.next/standalone/.next/static/chunks/{0w1f.k~gi-y6..js → 15_mi91qaeieu.js} +1 -1
  105. package/.next/standalone/.next/static/chunks/{0ufq8smh~i7wc.js → 18a9xv2p3~x.9.js} +1 -1
  106. package/.next/standalone/.next/static/chunks/{turbopack-0s36is87fc9r2.js → turbopack-0o7k.hakttp4k.js} +1 -1
  107. package/.next/standalone/app/components/cli-badge.tsx +24 -0
  108. package/.next/standalone/app/components/project-list.tsx +13 -7
  109. package/.next/standalone/app/components/sessions-list.tsx +4 -2
  110. package/.next/standalone/app/policies/hooks-client.tsx +66 -10
  111. package/.next/standalone/app/project/[name]/page.tsx +49 -22
  112. package/.next/standalone/app/project/[name]/session/[sessionId]/page.tsx +51 -19
  113. package/.next/standalone/components/reach-developers.tsx +6 -1
  114. package/.next/standalone/lib/codex-projects.ts +250 -0
  115. package/.next/standalone/lib/codex-sessions.ts +414 -0
  116. package/.next/standalone/lib/format-date.ts +21 -0
  117. package/.next/standalone/lib/log-entries.ts +3 -3
  118. package/.next/standalone/lib/paths.ts +13 -0
  119. package/.next/standalone/lib/projects.ts +57 -3
  120. package/.next/standalone/lib/utils.ts +6 -22
  121. package/.next/standalone/package.json +1 -1
  122. package/.next/standalone/server.js +1 -1
  123. package/bin/failproofai.mjs +1 -0
  124. package/dist/cli.mjs +1042 -122
  125. package/lib/codex-projects.ts +250 -0
  126. package/lib/codex-sessions.ts +414 -0
  127. package/lib/format-date.ts +21 -0
  128. package/lib/log-entries.ts +3 -3
  129. package/lib/paths.ts +13 -0
  130. package/lib/projects.ts +57 -3
  131. package/lib/utils.ts +6 -22
  132. package/package.json +1 -1
  133. package/scripts/launch.ts +2 -1
  134. package/src/hooks/builtin-policies.ts +7 -1
  135. package/src/hooks/hook-activity-store.ts +3 -0
  136. package/src/hooks/manager.ts +1 -1
  137. package/src/hooks/resolve-permission-mode.ts +6 -91
  138. package/.next/standalone/.next/server/chunks/[externals]__080wern._.js +0 -3
  139. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0b57.gk._.js +0 -3
  140. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__03rd.z8._.js +0 -3
  141. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0e74wa-._.js +0 -3
  142. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0vu.o-3._.js +0 -4
  143. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0w6l33k._.js +0 -17
  144. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0zqcovi._.js +0 -3
  145. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__105.l_7._.js +0 -3
  146. package/.next/standalone/.next/server/chunks/ssr/_0uy6m~m._.js +0 -3
  147. package/.next/standalone/.next/static/chunks/00b5h4r1el.6f.js +0 -1
  148. package/.next/standalone/.next/static/chunks/0fw2h.g66c0h3.js +0 -1
  149. package/.next/standalone/.next/static/chunks/0gu87mlr5ssnt.js +0 -6
  150. package/.next/standalone/.next/static/chunks/0igf3xbisp1lx.js +0 -1
  151. package/.next/standalone/.next/static/chunks/0p5zh2diw90a1.js +0 -1
  152. package/.next/standalone/.next/static/chunks/0vwqucikost_q.js +0 -1
  153. /package/.next/standalone/.next/static/{CiVeb_yiVt-O2JYrzGzB7 → SyaO-J1hupjAiRCG-Syzg}/_buildManifest.js +0 -0
  154. /package/.next/standalone/.next/static/{CiVeb_yiVt-O2JYrzGzB7 → SyaO-J1hupjAiRCG-Syzg}/_clientMiddlewareManifest.js +0 -0
  155. /package/.next/standalone/.next/static/{CiVeb_yiVt-O2JYrzGzB7 → SyaO-J1hupjAiRCG-Syzg}/_ssgManifest.js +0 -0
@@ -0,0 +1,250 @@
1
+ /**
2
+ * Codex (OpenAI) project discovery.
3
+ *
4
+ * Codex transcripts are stored at `~/.codex/sessions/<YYYY>/<MM>/<DD>/rollout-...jsonl`,
5
+ * keyed by date — not by working directory. To list "projects" we scan every transcript,
6
+ * read only the first record (`session_meta`, which carries `payload.cwd`), and group by cwd.
7
+ *
8
+ * The encoded cwd doubles as the URL slug for `/project/[name]`, matching Claude Code's
9
+ * convention (see `encodeFolderName` in `lib/paths.ts`), so a cwd present in both stores
10
+ * naturally produces the same `name` and can be merged on the Claude side.
11
+ */
12
+ import { open, readdir } from "fs/promises";
13
+ import { homedir } from "os";
14
+ import { join } from "path";
15
+ import { encodeFolderName } from "./paths";
16
+ import type { ProjectFolder, SessionFile } from "./projects";
17
+ import { runtimeCache } from "./runtime-cache";
18
+ import { batchAll } from "./concurrency";
19
+ import { formatDate } from "./format-date";
20
+ import { logWarn } from "./logger";
21
+
22
+ const CODEX_SESSIONS_ROOT = join(homedir(), ".codex", "sessions");
23
+ const SESSION_ID_RE = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i;
24
+ // session_meta records can be large (base instructions inlined), but a single record
25
+ // is unlikely to exceed a few hundred KB. 256 KB comfortably covers the first line
26
+ // without slurping a full multi-MB transcript.
27
+ const FIRST_LINE_CHUNK_BYTES = 256 * 1024;
28
+
29
+ interface CodexSessionMeta {
30
+ filePath: string;
31
+ fileName: string;
32
+ cwd: string;
33
+ sessionId: string;
34
+ fileMtime: Date;
35
+ }
36
+
37
+ async function safeReaddir(dir: string) {
38
+ try {
39
+ return await readdir(dir, { withFileTypes: true });
40
+ } catch {
41
+ return null;
42
+ }
43
+ }
44
+
45
+ /** Read the first line of a file without loading the rest. */
46
+ async function readFirstLine(filePath: string): Promise<string | null> {
47
+ let fh: Awaited<ReturnType<typeof open>> | null = null;
48
+ try {
49
+ fh = await open(filePath, "r");
50
+ const buf = Buffer.alloc(FIRST_LINE_CHUNK_BYTES);
51
+ const { bytesRead } = await fh.read(buf, 0, FIRST_LINE_CHUNK_BYTES, 0);
52
+ if (bytesRead === 0) return null;
53
+ const slice = buf.subarray(0, bytesRead);
54
+ const nl = slice.indexOf(0x0a); // '\n'
55
+ const end = nl === -1 ? bytesRead : nl;
56
+ return slice.subarray(0, end).toString("utf-8");
57
+ } catch {
58
+ return null;
59
+ } finally {
60
+ if (fh) await fh.close().catch(() => {});
61
+ }
62
+ }
63
+
64
+ function extractSessionMeta(line: string): { cwd?: string } {
65
+ try {
66
+ const obj = JSON.parse(line) as { type?: string; payload?: { cwd?: unknown } };
67
+ if (obj.type !== "session_meta") return {};
68
+ const cwd = obj.payload?.cwd;
69
+ if (typeof cwd !== "string" || cwd.length === 0) return {};
70
+ return { cwd };
71
+ } catch {
72
+ return {};
73
+ }
74
+ }
75
+
76
+ function extractSessionId(filename: string): string | null {
77
+ const m = filename.match(SESSION_ID_RE);
78
+ return m ? m[0] : null;
79
+ }
80
+
81
+ async function listJsonlFiles(dir: string): Promise<string[]> {
82
+ const entries = await safeReaddir(dir);
83
+ if (!entries) return [];
84
+ return entries
85
+ .filter((e) => e.isFile() && e.name.endsWith(".jsonl"))
86
+ .map((e) => join(dir, e.name));
87
+ }
88
+
89
+ /**
90
+ * Walk `~/.codex/sessions/<Y>/<M>/<D>/` for every `*.jsonl` transcript and read each
91
+ * file's first record to extract `cwd`. Files lacking a parsable `session_meta` or a
92
+ * UUID-looking sessionId in the filename are skipped.
93
+ */
94
+ async function scanCodexSessions(): Promise<CodexSessionMeta[]> {
95
+ const yearDirs = await safeReaddir(CODEX_SESSIONS_ROOT);
96
+ if (!yearDirs) return [];
97
+
98
+ const filePaths: string[] = [];
99
+ for (const y of yearDirs) {
100
+ if (!y.isDirectory()) continue;
101
+ const monthDirs = await safeReaddir(join(CODEX_SESSIONS_ROOT, y.name));
102
+ if (!monthDirs) continue;
103
+ for (const m of monthDirs) {
104
+ if (!m.isDirectory()) continue;
105
+ const dayDirs = await safeReaddir(join(CODEX_SESSIONS_ROOT, y.name, m.name));
106
+ if (!dayDirs) continue;
107
+ for (const d of dayDirs) {
108
+ if (!d.isDirectory()) continue;
109
+ const dayPath = join(CODEX_SESSIONS_ROOT, y.name, m.name, d.name);
110
+ filePaths.push(...(await listJsonlFiles(dayPath)));
111
+ }
112
+ }
113
+ }
114
+
115
+ if (filePaths.length === 0) return [];
116
+
117
+ const settled = await batchAll(
118
+ filePaths.map((filePath) => async (): Promise<CodexSessionMeta | null> => {
119
+ const sessionId = extractSessionId(filePath.split("/").pop() ?? "");
120
+ if (!sessionId) return null;
121
+ const line = await readFirstLine(filePath);
122
+ if (!line) return null;
123
+ const { cwd } = extractSessionMeta(line);
124
+ if (!cwd) return null;
125
+ let fileMtime: Date;
126
+ try {
127
+ const fh = await open(filePath, "r");
128
+ try {
129
+ const stat = await fh.stat();
130
+ fileMtime = stat.mtime;
131
+ } finally {
132
+ await fh.close().catch(() => {});
133
+ }
134
+ } catch {
135
+ fileMtime = new Date(0);
136
+ }
137
+ return {
138
+ filePath,
139
+ fileName: filePath.split("/").pop() ?? "",
140
+ cwd,
141
+ sessionId,
142
+ fileMtime,
143
+ };
144
+ }),
145
+ 16,
146
+ );
147
+ return settled
148
+ .filter((r): r is PromiseFulfilledResult<CodexSessionMeta | null> => r.status === "fulfilled")
149
+ .map((r) => r.value)
150
+ .filter((v): v is CodexSessionMeta => v !== null);
151
+ }
152
+
153
+ const cachedScan = runtimeCache(scanCodexSessions, 30);
154
+
155
+ /** Returns one ProjectFolder per unique cwd discovered in Codex transcripts. */
156
+ export async function getCodexProjects(): Promise<ProjectFolder[]> {
157
+ let metas: CodexSessionMeta[];
158
+ try {
159
+ metas = await cachedScan();
160
+ } catch (error) {
161
+ logWarn("Failed to scan Codex sessions:", error);
162
+ return [];
163
+ }
164
+
165
+ const byCwd = new Map<string, { latest: Date; cwd: string }>();
166
+ for (const m of metas) {
167
+ const existing = byCwd.get(m.cwd);
168
+ if (!existing || m.fileMtime.getTime() > existing.latest.getTime()) {
169
+ byCwd.set(m.cwd, { latest: m.fileMtime, cwd: m.cwd });
170
+ }
171
+ }
172
+
173
+ const folders: ProjectFolder[] = [];
174
+ for (const { cwd, latest } of byCwd.values()) {
175
+ folders.push({
176
+ name: encodeFolderName(cwd),
177
+ path: cwd,
178
+ isDirectory: true,
179
+ lastModified: latest,
180
+ lastModifiedFormatted: formatDate(latest),
181
+ cli: ["codex"],
182
+ });
183
+ }
184
+ folders.sort((a, b) => b.lastModified.getTime() - a.lastModified.getTime());
185
+ return folders;
186
+ }
187
+
188
+ function metasToSessionFiles(metas: CodexSessionMeta[]): SessionFile[] {
189
+ const files: SessionFile[] = metas.map((m) => ({
190
+ name: m.fileName.replace(/\.jsonl$/, ""),
191
+ path: m.filePath,
192
+ lastModified: m.fileMtime,
193
+ lastModifiedFormatted: formatDate(m.fileMtime),
194
+ sessionId: m.sessionId,
195
+ cli: "codex",
196
+ }));
197
+ files.sort((a, b) => b.lastModified.getTime() - a.lastModified.getTime());
198
+ return files;
199
+ }
200
+
201
+ /** Returns SessionFile entries for every Codex transcript whose cwd matches `cwd` exactly. */
202
+ export async function getCodexSessionsForCwd(cwd: string): Promise<SessionFile[]> {
203
+ let metas: CodexSessionMeta[];
204
+ try {
205
+ metas = await cachedScan();
206
+ } catch (error) {
207
+ logWarn("Failed to scan Codex sessions:", error);
208
+ return [];
209
+ }
210
+ return metasToSessionFiles(metas.filter((m) => m.cwd === cwd));
211
+ }
212
+
213
+ export interface CodexProjectByName {
214
+ /** Original cwd recovered from the Codex transcripts (canonical, not the lossy decode). */
215
+ cwd: string | null;
216
+ sessions: SessionFile[];
217
+ }
218
+
219
+ /**
220
+ * Looks up Codex sessions for a project URL slug. `decodeFolderName` is lossy
221
+ * (every `-` becomes `/`), so we cannot recover the original cwd from the slug —
222
+ * instead we re-encode each session's cwd and match in that direction. Returns
223
+ * both the canonical cwd (first match wins) and the matching sessions.
224
+ */
225
+ export async function getCodexSessionsByEncodedName(name: string): Promise<CodexProjectByName> {
226
+ let metas: CodexSessionMeta[];
227
+ try {
228
+ metas = await cachedScan();
229
+ } catch (error) {
230
+ logWarn("Failed to scan Codex sessions:", error);
231
+ return { cwd: null, sessions: [] };
232
+ }
233
+ const matches = metas.filter((m) => encodeFolderName(m.cwd) === name);
234
+ return {
235
+ cwd: matches[0]?.cwd ?? null,
236
+ sessions: metasToSessionFiles(matches),
237
+ };
238
+ }
239
+
240
+ export const getCachedCodexProjects = runtimeCache(getCodexProjects, 30);
241
+ export const getCachedCodexSessionsForCwd = runtimeCache(
242
+ (cwd: string) => getCodexSessionsForCwd(cwd),
243
+ 30,
244
+ { maxSize: 50 },
245
+ );
246
+ export const getCachedCodexSessionsByEncodedName = runtimeCache(
247
+ (name: string) => getCodexSessionsByEncodedName(name),
248
+ 30,
249
+ { maxSize: 50 },
250
+ );
@@ -0,0 +1,414 @@
1
+ /**
2
+ * Codex (OpenAI) session transcript discovery + JSONL parser.
3
+ *
4
+ * Codex stores transcripts at:
5
+ * ~/.codex/sessions/<YYYY>/<MM>/<DD>/<file containing sessionId>.jsonl
6
+ *
7
+ * The schema is uniform `{ timestamp, type, payload }` records:
8
+ * - type: session_meta — metadata (cwd, model, base instructions)
9
+ * - type: turn_context — per-turn config (approval_policy, sandbox)
10
+ * - type: response_item — message / function_call / function_call_output
11
+ * - type: event_msg — task_started, user_message, agent_message,
12
+ * exec_command_begin/end, token_count, …
13
+ *
14
+ * `parseCodexLog` maps these into the same `LogEntry` shapes the Claude
15
+ * parser produces (`lib/log-entries.ts`) so the existing log viewer renders
16
+ * Codex sessions without any UI-side branching.
17
+ */
18
+ import { readFileSync, readdirSync, existsSync, writeFileSync, mkdirSync, statSync } from "node:fs";
19
+ import { readFile } from "node:fs/promises";
20
+ import { dirname, join } from "node:path";
21
+ import { homedir } from "node:os";
22
+ import { runtimeCache } from "./runtime-cache";
23
+ import {
24
+ baseEntry,
25
+ formatTimestamp,
26
+ type LogEntry,
27
+ type UserEntry,
28
+ type AssistantEntry,
29
+ type GenericEntry,
30
+ type QueueOperationEntry,
31
+ type ContentBlock,
32
+ type ToolUseBlock,
33
+ type LogSource,
34
+ } from "./log-entries";
35
+ import { formatDuration } from "./format-duration";
36
+
37
+ // ── Transcript discovery ──
38
+
39
+ const CACHE_PATH = join(homedir(), ".failproofai", "cache", "codex-session-paths.json");
40
+
41
+ function readCache(): Record<string, string> {
42
+ try {
43
+ if (!existsSync(CACHE_PATH)) return {};
44
+ return JSON.parse(readFileSync(CACHE_PATH, "utf-8")) as Record<string, string>;
45
+ } catch {
46
+ return {};
47
+ }
48
+ }
49
+
50
+ function writeCacheEntry(sessionId: string, path: string): void {
51
+ try {
52
+ mkdirSync(dirname(CACHE_PATH), { recursive: true });
53
+ const cache = readCache();
54
+ cache[sessionId] = path;
55
+ writeFileSync(CACHE_PATH, JSON.stringify(cache), "utf-8");
56
+ } catch {
57
+ // Cache is best-effort
58
+ }
59
+ }
60
+
61
+ function dirSearch(dir: string, sessionId: string): string | null {
62
+ try {
63
+ for (const f of readdirSync(dir, { withFileTypes: true })) {
64
+ if (f.isFile() && f.name.includes(sessionId) && f.name.endsWith(".jsonl")) {
65
+ return join(dir, f.name);
66
+ }
67
+ }
68
+ } catch {
69
+ // dir doesn't exist or unreadable
70
+ }
71
+ return null;
72
+ }
73
+
74
+ /**
75
+ * Locate a Codex transcript by sessionId. Tries the cache, then today/
76
+ * yesterday's date directories, then a full tree scan as fallback.
77
+ * Synchronous so the hook hot path can call it without awaits.
78
+ */
79
+ export function findCodexTranscript(sessionId: string): string | null {
80
+ const cache = readCache();
81
+ const cached = cache[sessionId];
82
+ if (cached && existsSync(cached)) return cached;
83
+
84
+ const root = join(homedir(), ".codex", "sessions");
85
+
86
+ const today = new Date();
87
+ const yesterday = new Date(today.getTime() - 24 * 60 * 60 * 1000);
88
+ const datedDirs = [today, yesterday].map((d) => {
89
+ const y = String(d.getUTCFullYear());
90
+ const m = String(d.getUTCMonth() + 1).padStart(2, "0");
91
+ const day = String(d.getUTCDate()).padStart(2, "0");
92
+ return join(root, y, m, day);
93
+ });
94
+ for (const dir of datedDirs) {
95
+ const hit = dirSearch(dir, sessionId);
96
+ if (hit) {
97
+ writeCacheEntry(sessionId, hit);
98
+ return hit;
99
+ }
100
+ }
101
+
102
+ try {
103
+ for (const y of readdirSync(root, { withFileTypes: true })) {
104
+ if (!y.isDirectory()) continue;
105
+ for (const m of readdirSync(join(root, y.name), { withFileTypes: true })) {
106
+ if (!m.isDirectory()) continue;
107
+ for (const d of readdirSync(join(root, y.name, m.name), { withFileTypes: true })) {
108
+ if (!d.isDirectory()) continue;
109
+ const hit = dirSearch(join(root, y.name, m.name, d.name), sessionId);
110
+ if (hit) {
111
+ writeCacheEntry(sessionId, hit);
112
+ return hit;
113
+ }
114
+ }
115
+ }
116
+ }
117
+ } catch {
118
+ // Session may not have flushed yet, or the path doesn't exist
119
+ }
120
+ return null;
121
+ }
122
+
123
+ // ── Parser ──
124
+
125
+ interface CodexRecord {
126
+ timestamp?: string;
127
+ type?: string;
128
+ payload?: Record<string, unknown>;
129
+ }
130
+
131
+ interface CodexContentBlock {
132
+ type?: string;
133
+ text?: string;
134
+ }
135
+
136
+ interface CodexParseResult {
137
+ entries: LogEntry[];
138
+ rawLines: Record<string, unknown>[];
139
+ /** Working directory pulled from the first session_meta record, when present. */
140
+ cwd?: string;
141
+ }
142
+
143
+ function safeJsonParse(s: string | undefined): Record<string, unknown> {
144
+ if (!s) return {};
145
+ try {
146
+ return JSON.parse(s) as Record<string, unknown>;
147
+ } catch {
148
+ return {};
149
+ }
150
+ }
151
+
152
+ function joinTexts(blocks: CodexContentBlock[] | undefined, wantedType: "input_text" | "output_text"): string {
153
+ if (!Array.isArray(blocks)) return "";
154
+ return blocks
155
+ .filter((b) => b?.type === wantedType && typeof b.text === "string")
156
+ .map((b) => b.text as string)
157
+ .join("\n");
158
+ }
159
+
160
+ /**
161
+ * Parse a Codex JSONL transcript into `LogEntry[]` plus the raw lines.
162
+ * Yields to the event loop every 200 lines so big transcripts don't block.
163
+ */
164
+ export async function parseCodexLog(
165
+ fileContent: string,
166
+ source: LogSource = "session",
167
+ ): Promise<CodexParseResult> {
168
+ const lines = fileContent.split("\n").filter((line) => line.trim() !== "");
169
+
170
+ const entries: LogEntry[] = [];
171
+ const rawLines: Record<string, unknown>[] = [];
172
+ // call_id → tool_use block, so we can attach exec_command_end results back to the originating call.
173
+ const toolUseById = new Map<string, ToolUseBlock>();
174
+ // call_id → tool_use entry timestamp, used to compute durationMs from end records that lack a duration.
175
+ const toolUseStartMs = new Map<string, number>();
176
+ let cwd: string | undefined;
177
+ let seenTaskStart = false;
178
+
179
+ for (let i = 0; i < lines.length; i++) {
180
+ if (i > 0 && i % 200 === 0) await new Promise<void>((r) => setImmediate(r));
181
+
182
+ const line = lines[i];
183
+ let raw: CodexRecord;
184
+ try {
185
+ raw = JSON.parse(line) as CodexRecord;
186
+ } catch {
187
+ continue;
188
+ }
189
+
190
+ const rawCopy = { ...(raw as Record<string, unknown>), _source: source };
191
+ rawLines.push(rawCopy);
192
+
193
+ const timestamp = raw.timestamp;
194
+ if (!timestamp) continue;
195
+ const date = new Date(timestamp);
196
+ if (Number.isNaN(date.getTime())) continue;
197
+
198
+ const recType = raw.type;
199
+ const payload = raw.payload ?? {};
200
+
201
+ if (recType === "session_meta") {
202
+ const c = payload.cwd;
203
+ if (typeof c === "string" && !cwd) cwd = c;
204
+ entries.push({
205
+ type: "system",
206
+ ...baseEntry(rawCopy, timestamp, date, source),
207
+ raw: rawCopy,
208
+ } satisfies GenericEntry);
209
+ continue;
210
+ }
211
+
212
+ if (recType === "response_item") {
213
+ const subType = payload.type as string | undefined;
214
+
215
+ if (subType === "message") {
216
+ const role = payload.role as string | undefined;
217
+ const content = payload.content as CodexContentBlock[] | undefined;
218
+
219
+ if (role === "user" || role === "developer") {
220
+ const text = joinTexts(content, "input_text");
221
+ if (!text) continue;
222
+ const message = role === "developer" ? `[developer] ${text}` : text;
223
+ entries.push({
224
+ type: "user",
225
+ ...baseEntry(rawCopy, timestamp, date, source),
226
+ message: { role: "user", content: message },
227
+ } satisfies UserEntry);
228
+ continue;
229
+ }
230
+
231
+ if (role === "assistant") {
232
+ const text = joinTexts(content, "output_text");
233
+ if (!text) continue;
234
+ const blocks: ContentBlock[] = [{ type: "text", text }];
235
+ entries.push({
236
+ type: "assistant",
237
+ ...baseEntry(rawCopy, timestamp, date, source),
238
+ message: { role: "assistant", content: blocks },
239
+ } satisfies AssistantEntry);
240
+ continue;
241
+ }
242
+
243
+ // Unknown role — preserve as system so nothing is lost.
244
+ entries.push({
245
+ type: "system",
246
+ ...baseEntry(rawCopy, timestamp, date, source),
247
+ raw: rawCopy,
248
+ } satisfies GenericEntry);
249
+ continue;
250
+ }
251
+
252
+ if (subType === "function_call") {
253
+ const callId = payload.call_id as string | undefined;
254
+ const name = (payload.name as string | undefined) ?? "function_call";
255
+ const input = safeJsonParse(payload.arguments as string | undefined);
256
+ const toolUse: ToolUseBlock = {
257
+ type: "tool_use",
258
+ id: callId ?? `${timestamp}-${name}`,
259
+ name,
260
+ input,
261
+ };
262
+ const entry: AssistantEntry = {
263
+ type: "assistant",
264
+ ...baseEntry(rawCopy, timestamp, date, source),
265
+ message: { role: "assistant", content: [toolUse] },
266
+ };
267
+ entries.push(entry);
268
+ if (callId) {
269
+ toolUseById.set(callId, toolUse);
270
+ toolUseStartMs.set(callId, date.getTime());
271
+ }
272
+ continue;
273
+ }
274
+
275
+ if (subType === "function_call_output") {
276
+ const callId = payload.call_id as string | undefined;
277
+ const block = callId ? toolUseById.get(callId) : undefined;
278
+ if (block) {
279
+ const startMs = toolUseStartMs.get(callId!) ?? date.getTime();
280
+ const duration = Math.max(0, date.getTime() - startMs);
281
+ block.result = {
282
+ timestamp,
283
+ timestampFormatted: formatTimestamp(date),
284
+ content: typeof payload.output === "string" ? (payload.output as string) : JSON.stringify(payload.output),
285
+ durationMs: duration,
286
+ durationFormatted: formatDuration(duration),
287
+ };
288
+ continue;
289
+ }
290
+ // Orphan output — preserve as system.
291
+ entries.push({
292
+ type: "system",
293
+ ...baseEntry(rawCopy, timestamp, date, source),
294
+ raw: rawCopy,
295
+ } satisfies GenericEntry);
296
+ continue;
297
+ }
298
+
299
+ // Unknown response_item subtype — preserve raw.
300
+ entries.push({
301
+ type: "system",
302
+ ...baseEntry(rawCopy, timestamp, date, source),
303
+ raw: rawCopy,
304
+ } satisfies GenericEntry);
305
+ continue;
306
+ }
307
+
308
+ if (recType === "event_msg") {
309
+ const subType = payload.type as string | undefined;
310
+
311
+ if (subType === "task_started") {
312
+ const label: QueueOperationEntry["label"] = seenTaskStart ? "Session Resumed" : "Session Started";
313
+ seenTaskStart = true;
314
+ entries.push({
315
+ type: "queue-operation",
316
+ ...baseEntry(rawCopy, timestamp, date, source),
317
+ label,
318
+ } satisfies QueueOperationEntry);
319
+ continue;
320
+ }
321
+
322
+ if (subType === "exec_command_end") {
323
+ const callId = payload.call_id as string | undefined;
324
+ const block = callId ? toolUseById.get(callId) : undefined;
325
+ if (block) {
326
+ const duration = payload.duration as { secs?: number; nanos?: number } | undefined;
327
+ const durationMs = duration
328
+ ? (duration.secs ?? 0) * 1000 + Math.round((duration.nanos ?? 0) / 1e6)
329
+ : Math.max(0, date.getTime() - (toolUseStartMs.get(callId!) ?? date.getTime()));
330
+ const aggregated = payload.aggregated_output;
331
+ block.result = {
332
+ timestamp,
333
+ timestampFormatted: formatTimestamp(date),
334
+ content: typeof aggregated === "string" ? aggregated : JSON.stringify(aggregated),
335
+ durationMs,
336
+ durationFormatted: formatDuration(durationMs),
337
+ };
338
+ continue;
339
+ }
340
+ // Orphan exec end — preserve as system.
341
+ entries.push({
342
+ type: "system",
343
+ ...baseEntry(rawCopy, timestamp, date, source),
344
+ raw: rawCopy,
345
+ } satisfies GenericEntry);
346
+ continue;
347
+ }
348
+
349
+ if (subType === "user_message" || subType === "agent_message") {
350
+ // Already rendered via the corresponding response_item; skip to avoid duplicates.
351
+ continue;
352
+ }
353
+
354
+ // Other event_msg subtypes (token_count, exec_command_begin, etc.) — preserve raw.
355
+ entries.push({
356
+ type: "system",
357
+ ...baseEntry(rawCopy, timestamp, date, source),
358
+ raw: rawCopy,
359
+ } satisfies GenericEntry);
360
+ continue;
361
+ }
362
+
363
+ // turn_context and any unrecognized type — preserve raw so nothing is silently dropped.
364
+ entries.push({
365
+ type: "system",
366
+ ...baseEntry(rawCopy, timestamp, date, source),
367
+ raw: rawCopy,
368
+ } satisfies GenericEntry);
369
+ }
370
+
371
+ if (entries.length > 500) await new Promise<void>((r) => setImmediate(r));
372
+ entries.sort((a, b) => a.timestampMs - b.timestampMs);
373
+
374
+ return { entries, rawLines, cwd };
375
+ }
376
+
377
+ // ── Public loader ──
378
+
379
+ export interface CodexSessionLogData {
380
+ entries: LogEntry[];
381
+ rawLines: Record<string, unknown>[];
382
+ cwd?: string;
383
+ filePath: string;
384
+ }
385
+
386
+ export async function getCodexSessionLog(sessionId: string): Promise<CodexSessionLogData | null> {
387
+ const filePath = findCodexTranscript(sessionId);
388
+ if (!filePath) return null;
389
+ const fileContent = await readFile(filePath, "utf-8");
390
+ const { entries, rawLines, cwd } = await parseCodexLog(fileContent, "session");
391
+ return { entries, rawLines, cwd, filePath };
392
+ }
393
+
394
+ export const getCachedCodexSessionLog = runtimeCache(
395
+ (sessionId: string) => getCodexSessionLog(sessionId),
396
+ 60,
397
+ { maxSize: 50 },
398
+ );
399
+
400
+ // ── Test helpers ──
401
+
402
+ /** For tests: inspect cache file path. */
403
+ export function _getCacheFilePath(): string {
404
+ return CACHE_PATH;
405
+ }
406
+
407
+ /** For tests: confirm the file exists at a path. Wraps fs to keep tests minimal. */
408
+ export function _statFile(path: string): { isFile: boolean } | null {
409
+ try {
410
+ return { isFile: statSync(path).isFile() };
411
+ } catch {
412
+ return null;
413
+ }
414
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Formats a date to a readable string format (e.g., "Jan 15, 2024, 3:45 PM").
3
+ *
4
+ * Creates a new Intl.DateTimeFormat on each call intentionally — this runs
5
+ * server-side where there's no shared state concern. The client-side hot-path
6
+ * formatter in lib/log-format.ts caches its instance at module scope instead.
7
+ *
8
+ * Lives in its own module (rather than lib/utils.ts) so server-side callers
9
+ * — including the hook handler's transitive imports — don't need to pull in
10
+ * clsx/tailwind-merge just to format a date.
11
+ */
12
+ export function formatDate(date: Date): string {
13
+ return new Intl.DateTimeFormat("en-US", {
14
+ month: "short",
15
+ day: "numeric",
16
+ year: "numeric",
17
+ hour: "numeric",
18
+ minute: "2-digit",
19
+ hour12: true,
20
+ }).format(date);
21
+ }