failproofai 0.0.9 → 0.0.10-beta.0

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 (197) hide show
  1. package/.next/standalone/.cursor/hooks.json +47 -0
  2. package/.next/standalone/.gemini/settings.json +147 -0
  3. package/.next/standalone/.next/BUILD_ID +1 -1
  4. package/.next/standalone/.next/build-manifest.json +3 -3
  5. package/.next/standalone/.next/prerender-manifest.json +3 -3
  6. package/.next/standalone/.next/required-server-files.json +1 -1
  7. package/.next/standalone/.next/server/app/_global-error/page/server-reference-manifest.json +1 -1
  8. package/.next/standalone/.next/server/app/_global-error/page.js +1 -1
  9. package/.next/standalone/.next/server/app/_global-error/page.js.nft.json +1 -1
  10. package/.next/standalone/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
  11. package/.next/standalone/.next/server/app/_global-error.html +1 -1
  12. package/.next/standalone/.next/server/app/_global-error.rsc +7 -7
  13. package/.next/standalone/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +2 -2
  14. package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +7 -7
  15. package/.next/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +3 -3
  16. package/.next/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +3 -3
  17. package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  18. package/.next/standalone/.next/server/app/_not-found/page/server-reference-manifest.json +1 -1
  19. package/.next/standalone/.next/server/app/_not-found/page.js +1 -1
  20. package/.next/standalone/.next/server/app/_not-found/page.js.nft.json +1 -1
  21. package/.next/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  22. package/.next/standalone/.next/server/app/_not-found.html +2 -2
  23. package/.next/standalone/.next/server/app/_not-found.rsc +17 -17
  24. package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +17 -17
  25. package/.next/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +4 -4
  26. package/.next/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +11 -11
  27. package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +2 -2
  28. package/.next/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +3 -3
  29. package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
  30. package/.next/standalone/.next/server/app/api/download/[project]/[session]/route.js +2 -1
  31. package/.next/standalone/.next/server/app/api/download/[project]/[session]/route.js.nft.json +1 -1
  32. package/.next/standalone/.next/server/app/index.html +1 -1
  33. package/.next/standalone/.next/server/app/index.rsc +16 -16
  34. package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +2 -2
  35. package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +16 -16
  36. package/.next/standalone/.next/server/app/index.segments/_head.segment.rsc +4 -4
  37. package/.next/standalone/.next/server/app/index.segments/_index.segment.rsc +11 -11
  38. package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +2 -2
  39. package/.next/standalone/.next/server/app/page/server-reference-manifest.json +1 -1
  40. package/.next/standalone/.next/server/app/page.js +1 -1
  41. package/.next/standalone/.next/server/app/page.js.nft.json +1 -1
  42. package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
  43. package/.next/standalone/.next/server/app/policies/page/server-reference-manifest.json +8 -8
  44. package/.next/standalone/.next/server/app/policies/page.js +1 -1
  45. package/.next/standalone/.next/server/app/policies/page.js.nft.json +1 -1
  46. package/.next/standalone/.next/server/app/policies/page_client-reference-manifest.js +1 -1
  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 +2 -2
  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/react-loadable-manifest.json +2 -2
  52. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/server-reference-manifest.json +2 -2
  53. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js +5 -5
  54. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js.nft.json +1 -1
  55. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page_client-reference-manifest.js +1 -1
  56. package/.next/standalone/.next/server/app/projects/page/server-reference-manifest.json +1 -1
  57. package/.next/standalone/.next/server/app/projects/page.js +2 -2
  58. package/.next/standalone/.next/server/app/projects/page.js.nft.json +1 -1
  59. package/.next/standalone/.next/server/app/projects/page_client-reference-manifest.js +1 -1
  60. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0.~nmr9._.js +3 -0
  61. package/.next/standalone/.next/server/chunks/{[root-of-the-server]__0yspgjy._.js → [root-of-the-server]__010i6f5._.js} +2 -2
  62. package/.next/standalone/.next/server/chunks/[root-of-the-server]__08px0ym._.js +3 -0
  63. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0b57.gk._.js +3 -0
  64. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0dtn9lr._.js +3 -0
  65. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0kjo7d_._.js +1 -1
  66. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0vlhtkc._.js +3 -0
  67. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0wu7fr7._.js +3 -0
  68. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0yfq1yr._.js +3 -0
  69. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0z4c5dj._.js +3 -0
  70. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0zso~62._.js +3 -0
  71. package/.next/standalone/.next/server/chunks/package_json_[json]_cjs_0z7w.hh._.js +1 -1
  72. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0-2wr.c._.js +4 -0
  73. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0.~m-w2._.js +4 -0
  74. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__09icjsf._.js → [root-of-the-server]__0709m8.._.js} +3 -3
  75. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0bz245.._.js +4 -0
  76. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0dl0kgt._.js +4 -0
  77. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0gmhxyo._.js +4 -0
  78. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0mup1hi._.js +3 -0
  79. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0ohb3gc._.js +4 -0
  80. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0qbpe_v._.js +3 -0
  81. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0s~gy6y._.js +3 -0
  82. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0t5l7a5._.js +3 -0
  83. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0ymlddl._.js +152 -6
  84. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0_b7pgn._.js → [root-of-the-server]__0ymn496._.js} +2 -2
  85. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__01g_w_e._.js → [root-of-the-server]__10h.ggz._.js} +2 -2
  86. package/.next/standalone/.next/server/chunks/ssr/_03d7qyt._.js +3 -0
  87. package/.next/standalone/.next/server/chunks/ssr/{_07a1g.3._.js → _0zx~s__._.js} +2 -2
  88. package/.next/standalone/.next/server/chunks/ssr/_10lm7or._.js +2 -2
  89. package/.next/standalone/.next/server/chunks/ssr/app_0cdqd9w._.js +1 -1
  90. package/.next/standalone/.next/server/chunks/ssr/app_global-error_tsx_0xerkr6._.js +1 -1
  91. package/.next/standalone/.next/server/chunks/ssr/app_policies_hooks-client_tsx_0q-m0y-._.js +2 -2
  92. package/.next/standalone/.next/server/chunks/ssr/lib_codex-projects_ts_0eosib~._.js +1 -1
  93. package/.next/standalone/.next/server/chunks/ssr/lib_copilot-projects_ts_0r8xkn8._.js +3 -0
  94. package/.next/standalone/.next/server/chunks/ssr/lib_cursor-projects_ts_0qt1scg._.js +3 -0
  95. package/.next/standalone/.next/server/chunks/ssr/lib_gemini-projects_ts_0sl~yqr._.js +3 -0
  96. package/.next/standalone/.next/server/chunks/ssr/lib_opencode-projects_ts_0op9gyp._.js +3 -0
  97. package/.next/standalone/.next/server/chunks/ssr/lib_pi-projects_ts_103tsh1._.js +3 -0
  98. package/.next/standalone/.next/server/middleware-build-manifest.js +3 -3
  99. package/.next/standalone/.next/server/pages/404.html +2 -2
  100. package/.next/standalone/.next/server/pages/500.html +1 -1
  101. package/.next/standalone/.next/server/server-reference-manifest.js +1 -1
  102. package/.next/standalone/.next/server/server-reference-manifest.json +9 -9
  103. package/.next/standalone/.next/static/chunks/{0n-_j_6fo6jex.js → 00ay03h8bq4b~.js} +2 -2
  104. package/.next/standalone/.next/static/chunks/{11kt_9zaooda3.js → 0agmlhk5ml7x5.js} +1 -1
  105. package/.next/standalone/.next/static/chunks/0bi2r.m~yokoo.js +1 -0
  106. package/.next/standalone/.next/static/chunks/{095l4hc7-h.~~.js → 0en4v5k2nnxks.js} +1 -1
  107. package/.next/standalone/.next/static/chunks/0q5bmqop--9yk.js +1 -0
  108. package/.next/standalone/.next/static/chunks/{0756i.7omnnl6.js → 0s6nux54y~l~r.js} +1 -1
  109. package/.next/standalone/.next/static/chunks/{0t~iusm_fxoao.js → 0tpse0wu2wwo0.js} +1 -1
  110. package/.next/standalone/.next/static/chunks/12po2vpc-4_c1.css +1 -0
  111. package/.next/standalone/.next/static/chunks/{0u-ys71jc4y68.js → 1400rtd5ywbt..js} +2 -2
  112. package/.next/standalone/.next/static/chunks/{09ose_165ra4d.js → 14lmf8boay-zu.js} +1 -1
  113. package/.next/standalone/.next/static/chunks/{0pr7k36o_.du1.js → 17htukxga7bil.js} +1 -1
  114. package/.next/standalone/.opencode/opencode.json +4 -0
  115. package/.next/standalone/.opencode/plugins/failproofai.mjs +131 -0
  116. package/.next/standalone/.pi/settings.json +5 -0
  117. package/.next/standalone/app/components/cli-badge.tsx +7 -11
  118. package/.next/standalone/app/components/project-list.tsx +32 -4
  119. package/.next/standalone/app/policies/hooks-client.tsx +31 -15
  120. package/.next/standalone/app/project/[name]/page.tsx +52 -16
  121. package/.next/standalone/app/project/[name]/session/[sessionId]/page.tsx +92 -15
  122. package/.next/standalone/assets/logos/copilot-dark.svg +1 -0
  123. package/.next/standalone/assets/logos/copilot-light.svg +1 -0
  124. package/.next/standalone/assets/logos/cursor-dark.svg +1 -0
  125. package/.next/standalone/assets/logos/cursor-light.svg +1 -0
  126. package/.next/standalone/assets/logos/gemini-dark.svg +13 -0
  127. package/.next/standalone/assets/logos/gemini-light.svg +13 -0
  128. package/.next/standalone/assets/logos/opencode-dark.svg +1 -0
  129. package/.next/standalone/assets/logos/opencode-light.svg +1 -0
  130. package/.next/standalone/assets/logos/pi-dark.svg +7 -0
  131. package/.next/standalone/assets/logos/pi-light.svg +7 -0
  132. package/.next/standalone/lib/cli-registry.ts +107 -0
  133. package/.next/standalone/lib/codex-projects.ts +3 -3
  134. package/.next/standalone/lib/copilot-projects.ts +224 -0
  135. package/.next/standalone/lib/copilot-sessions.ts +395 -0
  136. package/.next/standalone/lib/cursor-projects.ts +312 -0
  137. package/.next/standalone/lib/cursor-sessions.ts +467 -0
  138. package/.next/standalone/lib/gemini-projects.ts +203 -0
  139. package/.next/standalone/lib/gemini-sessions.ts +365 -0
  140. package/.next/standalone/lib/opencode-projects.ts +232 -0
  141. package/.next/standalone/lib/opencode-sessions.ts +237 -0
  142. package/.next/standalone/lib/pi-projects.ts +230 -0
  143. package/.next/standalone/lib/pi-sessions.ts +325 -0
  144. package/.next/standalone/lib/projects.ts +67 -31
  145. package/.next/standalone/next.config.ts +5 -4
  146. package/.next/standalone/package.json +2 -1
  147. package/.next/standalone/pi-extension/index.ts +373 -0
  148. package/.next/standalone/pi-extension/package.json +12 -0
  149. package/.next/standalone/server.js +1 -1
  150. package/README.md +37 -3
  151. package/bin/failproofai.mjs +61 -21
  152. package/dist/cli.mjs +2248 -246
  153. package/lib/cli-registry.ts +107 -0
  154. package/lib/codex-projects.ts +3 -3
  155. package/lib/copilot-projects.ts +224 -0
  156. package/lib/copilot-sessions.ts +395 -0
  157. package/lib/cursor-projects.ts +312 -0
  158. package/lib/cursor-sessions.ts +467 -0
  159. package/lib/gemini-projects.ts +203 -0
  160. package/lib/gemini-sessions.ts +365 -0
  161. package/lib/opencode-projects.ts +232 -0
  162. package/lib/opencode-sessions.ts +237 -0
  163. package/lib/pi-projects.ts +230 -0
  164. package/lib/pi-sessions.ts +325 -0
  165. package/lib/projects.ts +67 -31
  166. package/package.json +2 -1
  167. package/pi-extension/index.ts +373 -0
  168. package/pi-extension/package.json +12 -0
  169. package/scripts/translate-docs/mdx-translator.ts +56 -2
  170. package/scripts/translate-docs/translator.ts +1 -1
  171. package/src/hooks/builtin-policies.ts +84 -14
  172. package/src/hooks/handler.ts +67 -5
  173. package/src/hooks/install-prompt.ts +33 -10
  174. package/src/hooks/integrations.ts +1007 -6
  175. package/src/hooks/policy-evaluator.ts +299 -3
  176. package/src/hooks/resolve-permission-mode.ts +23 -0
  177. package/src/hooks/types.ts +307 -3
  178. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0g72weg._.js +0 -3
  179. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0su~k6f._.js +0 -3
  180. package/.next/standalone/.next/server/chunks/lib_codex-projects_ts_07qqk1g._.js +0 -3
  181. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__01743wx._.js +0 -3
  182. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__092s1ta._.js +0 -4
  183. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0g.lg8b._.js +0 -4
  184. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0gs6wz4._.js +0 -3
  185. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0h..k-e._.js +0 -4
  186. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0it81ys._.js +0 -3
  187. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0u4a9jq._.js +0 -4
  188. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__11pa2ra._.js +0 -4
  189. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__12.h2mg._.js +0 -3
  190. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__12t-wym._.js +0 -4
  191. package/.next/standalone/.next/server/chunks/ssr/_04w00cm._.js +0 -3
  192. package/.next/standalone/.next/static/chunks/0.rk1iwdt1d7c.css +0 -1
  193. package/.next/standalone/.next/static/chunks/06x4-d1~o-opr.js +0 -1
  194. package/.next/standalone/.next/static/chunks/0n~s0gafwnp2y.js +0 -1
  195. /package/.next/standalone/.next/static/{A_Ax17P33facL0OmIwFXj → 68TLSFdjAQYIulNHfP0QY}/_buildManifest.js +0 -0
  196. /package/.next/standalone/.next/static/{A_Ax17P33facL0OmIwFXj → 68TLSFdjAQYIulNHfP0QY}/_clientMiddlewareManifest.js +0 -0
  197. /package/.next/standalone/.next/static/{A_Ax17P33facL0OmIwFXj → 68TLSFdjAQYIulNHfP0QY}/_ssgManifest.js +0 -0
@@ -0,0 +1,224 @@
1
+ /**
2
+ * GitHub Copilot CLI project discovery.
3
+ *
4
+ * Copilot stores per-session state at `~/.copilot/session-state/<sessionId>/`,
5
+ * with a `workspace.yaml` carrying flat scalars: id, cwd, git_root, branch,
6
+ * repository, host_type, user_named, summary_count, created_at, updated_at,
7
+ * (optional) name, summary. We read this file (always present, even before
8
+ * any interaction creates events.jsonl) to extract the cwd. Sessions are
9
+ * grouped by unique cwd into `ProjectFolder` rows.
10
+ *
11
+ * The encoded cwd doubles as the URL slug for `/project/[name]`, matching the
12
+ * Claude Code convention (see `encodeFolderName` in `lib/paths.ts`), so a cwd
13
+ * present in multiple stores naturally produces the same `name` and merges in
14
+ * `lib/projects.ts`.
15
+ */
16
+ import { readdir, readFile, stat } from "node:fs/promises";
17
+ import { homedir } from "node:os";
18
+ import { join } from "node:path";
19
+ import { encodeFolderName } from "./paths";
20
+ import type { ProjectFolder, SessionFile } from "./projects";
21
+ import { runtimeCache } from "./runtime-cache";
22
+ import { batchAll } from "./concurrency";
23
+ import { formatDate } from "./format-date";
24
+ import { logWarn } from "./logger";
25
+
26
+ /** Inlined to avoid cross-module imports from `lib/copilot-sessions.ts` —
27
+ * keeping the dep tree independent prevents Turbopack from tracing
28
+ * Node-only modules (`fs/promises`, `os`) into the client bundle when the
29
+ * session viewer page statically imports `copilot-sessions`. Mirrors the
30
+ * pattern in `lib/codex-projects.ts`. */
31
+ function getCopilotSessionStateRoot(): string {
32
+ return join(process.env.COPILOT_HOME || join(homedir(), ".copilot"), "session-state");
33
+ }
34
+
35
+ interface CopilotSessionMeta {
36
+ workspacePath: string;
37
+ eventsPath: string;
38
+ sessionId: string;
39
+ cwd: string;
40
+ /** Latest of (workspace.yaml mtime, events.jsonl mtime if present). */
41
+ fileMtime: Date;
42
+ /** True iff `events.jsonl` exists. Workspace-only sessions (initialized but
43
+ * never sent a prompt) skip the `/project` session list because the viewer
44
+ * would only render "Session log file not found." */
45
+ hasTranscript: boolean;
46
+ }
47
+
48
+ async function safeReaddir(dir: string) {
49
+ try {
50
+ return await readdir(dir, { withFileTypes: true });
51
+ } catch {
52
+ return null;
53
+ }
54
+ }
55
+
56
+ /** Extract `cwd` from a workspace.yaml file. Permissive regex parser — avoids
57
+ * pulling in a YAML lib for one flat scalar. Copilot writes simple
58
+ * `key: value` lines without nesting in this file (verified against CLI 1.0.39). */
59
+ function parseCwdFromWorkspace(text: string): string | undefined {
60
+ const m = text.match(/^cwd\s*:\s*(.+?)\s*$/m);
61
+ if (!m) return undefined;
62
+ return m[1].replace(/^['"]|['"]$/g, "");
63
+ }
64
+
65
+ async function statMtime(path: string): Promise<Date | null> {
66
+ try {
67
+ return (await stat(path)).mtime;
68
+ } catch {
69
+ return null;
70
+ }
71
+ }
72
+
73
+ async function scanCopilotSessions(): Promise<CopilotSessionMeta[]> {
74
+ const root = getCopilotSessionStateRoot();
75
+ const entries = await safeReaddir(root);
76
+ if (!entries) return [];
77
+
78
+ const candidates = entries
79
+ .filter((e) => e.isDirectory())
80
+ .map((e) => ({
81
+ sessionId: e.name,
82
+ workspacePath: join(root, e.name, "workspace.yaml"),
83
+ eventsPath: join(root, e.name, "events.jsonl"),
84
+ }));
85
+
86
+ const settled = await batchAll(
87
+ candidates.map((c) => async (): Promise<CopilotSessionMeta | null> => {
88
+ let workspaceText: string;
89
+ try {
90
+ workspaceText = await readFile(c.workspacePath, "utf-8");
91
+ } catch {
92
+ return null;
93
+ }
94
+ const cwd = parseCwdFromWorkspace(workspaceText);
95
+ if (!cwd) return null;
96
+ // Prefer events.jsonl mtime when present (reflects last activity);
97
+ // fall back to workspace.yaml mtime for sessions without interaction.
98
+ const eventsMtime = await statMtime(c.eventsPath);
99
+ const wsMtime = await statMtime(c.workspacePath);
100
+ const hasTranscript = eventsMtime !== null;
101
+ const fileMtime =
102
+ eventsMtime && wsMtime
103
+ ? new Date(Math.max(eventsMtime.getTime(), wsMtime.getTime()))
104
+ : eventsMtime ?? wsMtime ?? new Date(0);
105
+ return {
106
+ workspacePath: c.workspacePath,
107
+ eventsPath: c.eventsPath,
108
+ sessionId: c.sessionId,
109
+ cwd,
110
+ fileMtime,
111
+ hasTranscript,
112
+ };
113
+ }),
114
+ 16,
115
+ );
116
+ return settled
117
+ .filter((r): r is PromiseFulfilledResult<CopilotSessionMeta | null> => r.status === "fulfilled")
118
+ .map((r) => r.value)
119
+ .filter((v): v is CopilotSessionMeta => v !== null);
120
+ }
121
+
122
+ const cachedScan = runtimeCache(scanCopilotSessions, 30);
123
+
124
+ /** Returns one ProjectFolder per unique cwd discovered in Copilot transcripts. */
125
+ export async function getCopilotProjects(): Promise<ProjectFolder[]> {
126
+ let metas: CopilotSessionMeta[];
127
+ try {
128
+ metas = await cachedScan();
129
+ } catch (error) {
130
+ logWarn("Failed to scan Copilot sessions:", error);
131
+ return [];
132
+ }
133
+
134
+ // Skip workspace-only sessions: their /projects rows would click through to
135
+ // an empty session list (metasToSessionFiles also filters on hasTranscript).
136
+ const byCwd = new Map<string, { latest: Date; cwd: string }>();
137
+ for (const m of metas) {
138
+ if (!m.hasTranscript) continue;
139
+ const existing = byCwd.get(m.cwd);
140
+ if (!existing || m.fileMtime.getTime() > existing.latest.getTime()) {
141
+ byCwd.set(m.cwd, { latest: m.fileMtime, cwd: m.cwd });
142
+ }
143
+ }
144
+
145
+ const folders: ProjectFolder[] = [];
146
+ for (const { cwd, latest } of byCwd.values()) {
147
+ folders.push({
148
+ name: encodeFolderName(cwd),
149
+ path: cwd,
150
+ isDirectory: true,
151
+ lastModified: latest,
152
+ lastModifiedFormatted: formatDate(latest),
153
+ cli: ["copilot"],
154
+ });
155
+ }
156
+ folders.sort((a, b) => b.lastModified.getTime() - a.lastModified.getTime());
157
+ return folders;
158
+ }
159
+
160
+ function metasToSessionFiles(metas: CopilotSessionMeta[]): SessionFile[] {
161
+ // Skip workspace-only sessions: their "openable" rows would lead to a
162
+ // 'Session log file not found' viewer because events.jsonl doesn't exist.
163
+ const files: SessionFile[] = metas
164
+ .filter((m) => m.hasTranscript)
165
+ .map((m) => ({
166
+ name: m.sessionId,
167
+ path: m.eventsPath,
168
+ lastModified: m.fileMtime,
169
+ lastModifiedFormatted: formatDate(m.fileMtime),
170
+ sessionId: m.sessionId,
171
+ cli: "copilot",
172
+ }));
173
+ files.sort((a, b) => b.lastModified.getTime() - a.lastModified.getTime());
174
+ return files;
175
+ }
176
+
177
+ /** Returns SessionFile entries for every Copilot transcript whose cwd matches `cwd` exactly. */
178
+ export async function getCopilotSessionsForCwd(cwd: string): Promise<SessionFile[]> {
179
+ let metas: CopilotSessionMeta[];
180
+ try {
181
+ metas = await cachedScan();
182
+ } catch (error) {
183
+ logWarn("Failed to scan Copilot sessions:", error);
184
+ return [];
185
+ }
186
+ return metasToSessionFiles(metas.filter((m) => m.cwd === cwd));
187
+ }
188
+
189
+ export interface CopilotProjectByName {
190
+ cwd: string | null;
191
+ sessions: SessionFile[];
192
+ }
193
+
194
+ /**
195
+ * Looks up Copilot sessions for a project URL slug. `decodeFolderName` is lossy
196
+ * (every `-` becomes `/`), so we re-encode each session's cwd and match in
197
+ * that direction. Returns both the canonical cwd and the matching sessions.
198
+ */
199
+ export async function getCopilotSessionsByEncodedName(name: string): Promise<CopilotProjectByName> {
200
+ let metas: CopilotSessionMeta[];
201
+ try {
202
+ metas = await cachedScan();
203
+ } catch (error) {
204
+ logWarn("Failed to scan Copilot sessions:", error);
205
+ return { cwd: null, sessions: [] };
206
+ }
207
+ const matches = metas.filter((m) => m.hasTranscript && encodeFolderName(m.cwd) === name);
208
+ return {
209
+ cwd: matches[0]?.cwd ?? null,
210
+ sessions: metasToSessionFiles(matches),
211
+ };
212
+ }
213
+
214
+ export const getCachedCopilotProjects = runtimeCache(getCopilotProjects, 30);
215
+ export const getCachedCopilotSessionsForCwd = runtimeCache(
216
+ (cwd: string) => getCopilotSessionsForCwd(cwd),
217
+ 30,
218
+ { maxSize: 50 },
219
+ );
220
+ export const getCachedCopilotSessionsByEncodedName = runtimeCache(
221
+ (name: string) => getCopilotSessionsByEncodedName(name),
222
+ 30,
223
+ { maxSize: 50 },
224
+ );
@@ -0,0 +1,395 @@
1
+ /**
2
+ * GitHub Copilot CLI session transcript discovery + JSONL parser.
3
+ *
4
+ * Copilot stores per-session state at:
5
+ * ~/.copilot/session-state/<sessionId>/
6
+ * workspace.yaml — session metadata (id, cwd, git_root, branch, …)
7
+ * events.jsonl — event log (only created after first interaction)
8
+ * session.db — per-session SQLite (cross-session index lives at ~/.copilot/session-store.db)
9
+ * checkpoints/index.md — checkpoint history
10
+ * files/, research/ — workspace artifacts
11
+ *
12
+ * (configurable via COPILOT_HOME). Each `events.jsonl` line is a record with
13
+ * shape `{ type, data, id, timestamp, parentId }` where `type` is a dotted
14
+ * path. Verified record types as of Copilot CLI 1.0.39:
15
+ * • session.start — data.sessionId, data.context.{cwd, gitRoot, branch, repository, headCommit}
16
+ * • session.model_change — data.newModel
17
+ * • session.shutdown — data.shutdownType, data.codeChanges, …
18
+ * • system.message — data.role, data.content
19
+ * • user.message — data.content, data.transformedContent
20
+ * • assistant.turn_start — data.turnId, data.interactionId
21
+ * • assistant.message — data.messageId, data.content, data.toolRequests
22
+ * • assistant.turn_end — data.turnId
23
+ * • tool.execution_start — data.toolCallId, data.toolName, data.arguments
24
+ * • tool.execution_complete — data.toolCallId, data.success, data.result.{content, detailedContent}
25
+ *
26
+ * Unknown record types are preserved as generic system entries so nothing is
27
+ * silently dropped.
28
+ *
29
+ * Refs:
30
+ * https://docs.github.com/en/copilot/concepts/agents/copilot-cli/chronicle
31
+ * https://docs.github.com/en/copilot/reference/copilot-cli-reference/cli-config-dir-reference
32
+ */
33
+ import { readFileSync, readdirSync, existsSync, statSync } from "node:fs";
34
+ import { readFile } from "node:fs/promises";
35
+ import { join, resolve, sep } from "node:path";
36
+ import { homedir } from "node:os";
37
+ import { runtimeCache } from "./runtime-cache";
38
+ import {
39
+ baseEntry,
40
+ formatTimestamp,
41
+ type LogEntry,
42
+ type UserEntry,
43
+ type AssistantEntry,
44
+ type GenericEntry,
45
+ type QueueOperationEntry,
46
+ type ContentBlock,
47
+ type ToolUseBlock,
48
+ type LogSource,
49
+ } from "./log-entries";
50
+ import { formatDuration } from "./format-duration";
51
+
52
+ // ── Paths ──
53
+
54
+ /** Root directory for Copilot CLI session state, honoring COPILOT_HOME. */
55
+ export function getCopilotHome(): string {
56
+ return process.env.COPILOT_HOME || join(homedir(), ".copilot");
57
+ }
58
+
59
+ export function getCopilotSessionStateRoot(): string {
60
+ return join(getCopilotHome(), "session-state");
61
+ }
62
+
63
+ /** Session-state dir for a given sessionId. Returns null if `sessionId` is
64
+ * empty or contains path-traversal segments that would escape the
65
+ * session-state root. */
66
+ export function getCopilotSessionDir(sessionId: string): string | null {
67
+ if (!sessionId) return null;
68
+ const root = resolve(getCopilotSessionStateRoot());
69
+ const candidate = resolve(root, sessionId);
70
+ // Containment check: the resolved path must be a child of `root`.
71
+ // (Equality means `sessionId` resolved to root itself — also rejected.)
72
+ if (candidate === root || !candidate.startsWith(`${root}${sep}`)) return null;
73
+ return candidate;
74
+ }
75
+
76
+ /** Resolve a single file under a session directory, applying the same
77
+ * containment check. Returns null if the sessionId is invalid. */
78
+ function resolveSessionFile(sessionId: string, filename: string): string | null {
79
+ const dir = getCopilotSessionDir(sessionId);
80
+ if (!dir) return null;
81
+ return join(dir, filename);
82
+ }
83
+
84
+ // ── Transcript discovery ──
85
+
86
+ /**
87
+ * Locate a Copilot CLI events.jsonl by sessionId. Copilot lays sessions out
88
+ * directly under `session-state/<sessionId>/events.jsonl` (only created after
89
+ * the first user interaction), so the lookup is a single existence check.
90
+ * Path-traversal sessionIds (`../foo`) are rejected.
91
+ *
92
+ * Synchronous so the hook hot path can call it without awaits.
93
+ */
94
+ export function findCopilotTranscript(sessionId: string): string | null {
95
+ const candidate = resolveSessionFile(sessionId, "events.jsonl");
96
+ if (!candidate) return null;
97
+ return existsSync(candidate) ? candidate : null;
98
+ }
99
+
100
+ /** Locate the workspace.yaml for a session (always present, even pre-interaction). */
101
+ export function findCopilotWorkspace(sessionId: string): string | null {
102
+ const candidate = resolveSessionFile(sessionId, "workspace.yaml");
103
+ if (!candidate) return null;
104
+ return existsSync(candidate) ? candidate : null;
105
+ }
106
+
107
+ /**
108
+ * Extract a single key from the YAML at `path` using a permissive regex
109
+ * (avoids adding a YAML parser dep for the handful of flat scalar fields
110
+ * Copilot writes). Returns the trimmed string value or undefined.
111
+ */
112
+ function readYamlScalar(path: string, key: string): string | undefined {
113
+ try {
114
+ const text = readFileSync(path, "utf-8");
115
+ const re = new RegExp(`^${key}\\s*:\\s*(.+?)\\s*$`, "m");
116
+ const m = text.match(re);
117
+ if (!m) return undefined;
118
+ // Strip surrounding quotes if any.
119
+ return m[1].replace(/^['"]|['"]$/g, "");
120
+ } catch {
121
+ return undefined;
122
+ }
123
+ }
124
+
125
+ /** Read the cwd recorded in workspace.yaml for a session. */
126
+ export function readCopilotWorkspaceCwd(sessionId: string): string | undefined {
127
+ const path = findCopilotWorkspace(sessionId);
128
+ if (!path) return undefined;
129
+ return readYamlScalar(path, "cwd");
130
+ }
131
+
132
+ // ── Parser ──
133
+
134
+ interface CopilotRecord {
135
+ type?: string;
136
+ data?: Record<string, unknown>;
137
+ id?: string;
138
+ timestamp?: string;
139
+ parentId?: string | null;
140
+ }
141
+
142
+ interface CopilotParseResult {
143
+ entries: LogEntry[];
144
+ rawLines: Record<string, unknown>[];
145
+ /** Working directory pulled from the first session.start record. */
146
+ cwd?: string;
147
+ }
148
+
149
+ interface CopilotToolResult {
150
+ content?: string;
151
+ detailedContent?: string;
152
+ }
153
+
154
+ interface CopilotToolTelemetry {
155
+ metrics?: { commandTimeMs?: number; durationMs?: number };
156
+ properties?: Record<string, unknown>;
157
+ }
158
+
159
+ /**
160
+ * Parse a Copilot CLI events.jsonl transcript into `LogEntry[]` plus the raw
161
+ * lines. Yields to the event loop every 200 lines so big transcripts don't
162
+ * block the request.
163
+ */
164
+ export async function parseCopilotLog(
165
+ fileContent: string,
166
+ source: LogSource = "session",
167
+ ): Promise<CopilotParseResult> {
168
+ const lines = fileContent.split("\n").filter((line) => line.trim() !== "");
169
+ const entries: LogEntry[] = [];
170
+ const rawLines: Record<string, unknown>[] = [];
171
+ // toolCallId → tool_use block, so we can attach tool.execution_complete back.
172
+ const toolUseById = new Map<string, ToolUseBlock>();
173
+ const toolUseStartMs = new Map<string, number>();
174
+ let cwd: string | undefined;
175
+ let seenSessionStart = false;
176
+
177
+ for (let i = 0; i < lines.length; i++) {
178
+ if (i > 0 && i % 200 === 0) await new Promise<void>((r) => setImmediate(r));
179
+
180
+ const line = lines[i];
181
+ let raw: CopilotRecord;
182
+ try {
183
+ raw = JSON.parse(line) as CopilotRecord;
184
+ } catch {
185
+ continue;
186
+ }
187
+
188
+ const rawCopy = { ...(raw as Record<string, unknown>), _source: source };
189
+ rawLines.push(rawCopy);
190
+
191
+ const timestampStr = raw.timestamp;
192
+ if (!timestampStr) continue;
193
+ const date = new Date(timestampStr);
194
+ if (Number.isNaN(date.getTime())) continue;
195
+ const timestamp = date.toISOString();
196
+
197
+ const recType = raw.type;
198
+ const data = raw.data ?? {};
199
+
200
+ if (recType === "session.start") {
201
+ const ctx = data.context as { cwd?: unknown } | undefined;
202
+ const c = ctx?.cwd;
203
+ if (typeof c === "string" && !cwd) cwd = c;
204
+ const label: QueueOperationEntry["label"] = seenSessionStart ? "Session Resumed" : "Session Started";
205
+ seenSessionStart = true;
206
+ entries.push({
207
+ type: "queue-operation",
208
+ ...baseEntry(rawCopy, timestamp, date, source),
209
+ label,
210
+ } satisfies QueueOperationEntry);
211
+ continue;
212
+ }
213
+
214
+ if (recType === "user.message") {
215
+ const text = (data.content as string) ?? "";
216
+ if (!text) continue;
217
+ entries.push({
218
+ type: "user",
219
+ ...baseEntry(rawCopy, timestamp, date, source),
220
+ message: { role: "user", content: text },
221
+ } satisfies UserEntry);
222
+ continue;
223
+ }
224
+
225
+ if (recType === "system.message") {
226
+ // System prompts are noisy; render as a generic system entry so they're
227
+ // visible in the raw view but don't dominate the structured timeline.
228
+ entries.push({
229
+ type: "system",
230
+ ...baseEntry(rawCopy, timestamp, date, source),
231
+ raw: rawCopy,
232
+ } satisfies GenericEntry);
233
+ continue;
234
+ }
235
+
236
+ if (recType === "assistant.message") {
237
+ const text = (data.content as string) ?? "";
238
+ if (!text) {
239
+ entries.push({
240
+ type: "system",
241
+ ...baseEntry(rawCopy, timestamp, date, source),
242
+ raw: rawCopy,
243
+ } satisfies GenericEntry);
244
+ continue;
245
+ }
246
+ const blocks: ContentBlock[] = [{ type: "text", text }];
247
+ entries.push({
248
+ type: "assistant",
249
+ ...baseEntry(rawCopy, timestamp, date, source),
250
+ message: { role: "assistant", content: blocks },
251
+ } satisfies AssistantEntry);
252
+ continue;
253
+ }
254
+
255
+ if (recType === "tool.execution_start") {
256
+ const callId = data.toolCallId as string | undefined;
257
+ const name = (data.toolName as string) ?? "tool";
258
+ const args = (data.arguments as Record<string, unknown>) ?? {};
259
+ const id = callId ?? `${date.getTime()}-${name}`;
260
+ const toolUse: ToolUseBlock = {
261
+ type: "tool_use",
262
+ id,
263
+ name,
264
+ input: args,
265
+ };
266
+ const entry: AssistantEntry = {
267
+ type: "assistant",
268
+ ...baseEntry(rawCopy, timestamp, date, source),
269
+ message: { role: "assistant", content: [toolUse] },
270
+ };
271
+ entries.push(entry);
272
+ if (callId) {
273
+ toolUseById.set(callId, toolUse);
274
+ toolUseStartMs.set(callId, date.getTime());
275
+ }
276
+ continue;
277
+ }
278
+
279
+ if (recType === "tool.execution_complete") {
280
+ const callId = data.toolCallId as string | undefined;
281
+ const block = callId ? toolUseById.get(callId) : undefined;
282
+ if (block) {
283
+ const startMs = toolUseStartMs.get(callId!) ?? date.getTime();
284
+ const result = (data.result as CopilotToolResult | undefined) ?? {};
285
+ const telemetry = (data.toolTelemetry as CopilotToolTelemetry | undefined) ?? {};
286
+ const reportedMs =
287
+ telemetry.metrics?.commandTimeMs ?? telemetry.metrics?.durationMs ?? null;
288
+ const durationMs =
289
+ typeof reportedMs === "number" && reportedMs >= 0
290
+ ? reportedMs
291
+ : Math.max(0, date.getTime() - startMs);
292
+ const content = result.detailedContent ?? result.content ?? "";
293
+ block.result = {
294
+ timestamp,
295
+ timestampFormatted: formatTimestamp(date),
296
+ content: typeof content === "string" ? content : JSON.stringify(content),
297
+ durationMs,
298
+ durationFormatted: formatDuration(durationMs),
299
+ };
300
+ continue;
301
+ }
302
+ // Orphan tool result — preserve as system.
303
+ entries.push({
304
+ type: "system",
305
+ ...baseEntry(rawCopy, timestamp, date, source),
306
+ raw: rawCopy,
307
+ } satisfies GenericEntry);
308
+ continue;
309
+ }
310
+
311
+ // assistant.turn_start, assistant.turn_end, session.model_change,
312
+ // session.shutdown, and any unrecognized type — preserve raw so nothing is
313
+ // silently dropped, but keep them out of the structured user/assistant
314
+ // timeline (they're scaffolding events).
315
+ entries.push({
316
+ type: "system",
317
+ ...baseEntry(rawCopy, timestamp, date, source),
318
+ raw: rawCopy,
319
+ } satisfies GenericEntry);
320
+ }
321
+
322
+ if (entries.length > 500) await new Promise<void>((r) => setImmediate(r));
323
+ entries.sort((a, b) => a.timestampMs - b.timestampMs);
324
+
325
+ return { entries, rawLines, cwd };
326
+ }
327
+
328
+ // ── Public loader ──
329
+
330
+ export interface CopilotSessionLogData {
331
+ entries: LogEntry[];
332
+ rawLines: Record<string, unknown>[];
333
+ cwd?: string;
334
+ filePath: string;
335
+ }
336
+
337
+ export async function getCopilotSessionLog(sessionId: string): Promise<CopilotSessionLogData | null> {
338
+ const filePath = findCopilotTranscript(sessionId);
339
+ if (!filePath) return null;
340
+ // findCopilotTranscript only proves existence at lookup time; the file can be
341
+ // removed/rotated before this readFile lands. Preserve the nullable contract
342
+ // instead of throwing into the session page.
343
+ let fileContent: string;
344
+ try {
345
+ fileContent = await readFile(filePath, "utf-8");
346
+ } catch {
347
+ return null;
348
+ }
349
+ const { entries, rawLines, cwd } = await parseCopilotLog(fileContent, "session");
350
+ // Fall back to workspace.yaml if events.jsonl didn't expose a session.start.
351
+ const resolvedCwd = cwd ?? readCopilotWorkspaceCwd(sessionId);
352
+ return { entries, rawLines, cwd: resolvedCwd, filePath };
353
+ }
354
+
355
+ export const getCachedCopilotSessionLog = runtimeCache(
356
+ (sessionId: string) => getCopilotSessionLog(sessionId),
357
+ 60,
358
+ { maxSize: 50 },
359
+ );
360
+
361
+ // ── Test helpers ──
362
+
363
+ /** For tests: read raw stat of the events.jsonl path, returning null on miss. */
364
+ export function _statTranscript(sessionId: string): { mtimeMs: number } | null {
365
+ const path = findCopilotTranscript(sessionId);
366
+ if (!path) return null;
367
+ try {
368
+ const s = statSync(path);
369
+ return { mtimeMs: s.mtimeMs };
370
+ } catch {
371
+ return null;
372
+ }
373
+ }
374
+
375
+ /** For tests: list session IDs found in session-state/. */
376
+ export function _listSessionIds(): string[] {
377
+ try {
378
+ return readdirSync(getCopilotSessionStateRoot(), { withFileTypes: true })
379
+ .filter((e) => e.isDirectory())
380
+ .map((e) => e.name);
381
+ } catch {
382
+ return [];
383
+ }
384
+ }
385
+
386
+ /** Surface a sync read variant used by lower-level code paths. */
387
+ export function readCopilotTranscriptSync(sessionId: string): string | null {
388
+ const path = findCopilotTranscript(sessionId);
389
+ if (!path) return null;
390
+ try {
391
+ return readFileSync(path, "utf-8");
392
+ } catch {
393
+ return null;
394
+ }
395
+ }