@agentex/agent 0.0.27 → 0.0.29

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 (199) hide show
  1. package/CHANGELOG.md +138 -0
  2. package/README.md +98 -8
  3. package/dist/derived.d.ts.map +1 -1
  4. package/dist/derived.js +99 -2
  5. package/dist/derived.js.map +1 -1
  6. package/dist/goals/controller.d.ts.map +1 -1
  7. package/dist/goals/controller.js +21 -0
  8. package/dist/goals/controller.js.map +1 -1
  9. package/dist/goals/normalize.d.ts +1 -0
  10. package/dist/goals/normalize.d.ts.map +1 -1
  11. package/dist/goals/normalize.js +5 -0
  12. package/dist/goals/normalize.js.map +1 -1
  13. package/dist/history/fs.d.ts +36 -0
  14. package/dist/history/fs.d.ts.map +1 -0
  15. package/dist/history/fs.js +266 -0
  16. package/dist/history/fs.js.map +1 -0
  17. package/dist/history/index.d.ts +3 -0
  18. package/dist/history/index.d.ts.map +1 -0
  19. package/dist/history/index.js +2 -0
  20. package/dist/history/index.js.map +1 -0
  21. package/dist/history/types.d.ts +75 -0
  22. package/dist/history/types.d.ts.map +1 -0
  23. package/dist/history/types.js +9 -0
  24. package/dist/history/types.js.map +1 -0
  25. package/dist/index.d.ts +3 -2
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/index.js.map +1 -1
  28. package/dist/providers/claude/attach.d.ts.map +1 -1
  29. package/dist/providers/claude/attach.js +50 -9
  30. package/dist/providers/claude/attach.js.map +1 -1
  31. package/dist/providers/claude/history.d.ts +3 -0
  32. package/dist/providers/claude/history.d.ts.map +1 -0
  33. package/dist/providers/claude/history.js +248 -0
  34. package/dist/providers/claude/history.js.map +1 -0
  35. package/dist/providers/claude/index.d.ts.map +1 -1
  36. package/dist/providers/claude/index.js +26 -0
  37. package/dist/providers/claude/index.js.map +1 -1
  38. package/dist/providers/claude/session.d.ts +2 -0
  39. package/dist/providers/claude/session.d.ts.map +1 -1
  40. package/dist/providers/claude/session.js +17 -10
  41. package/dist/providers/claude/session.js.map +1 -1
  42. package/dist/providers/codex/attach.d.ts +2 -2
  43. package/dist/providers/codex/attach.d.ts.map +1 -1
  44. package/dist/providers/codex/attach.js +44 -7
  45. package/dist/providers/codex/attach.js.map +1 -1
  46. package/dist/providers/codex/history.d.ts +3 -0
  47. package/dist/providers/codex/history.d.ts.map +1 -0
  48. package/dist/providers/codex/history.js +350 -0
  49. package/dist/providers/codex/history.js.map +1 -0
  50. package/dist/providers/codex/index.d.ts.map +1 -1
  51. package/dist/providers/codex/index.js +26 -0
  52. package/dist/providers/codex/index.js.map +1 -1
  53. package/dist/providers/codex/transcript-normalize.d.ts +3 -2
  54. package/dist/providers/codex/transcript-normalize.d.ts.map +1 -1
  55. package/dist/providers/codex/transcript-normalize.js +4 -3
  56. package/dist/providers/codex/transcript-normalize.js.map +1 -1
  57. package/dist/providers/codex/transcript.d.ts +3 -1
  58. package/dist/providers/codex/transcript.d.ts.map +1 -1
  59. package/dist/providers/codex/transcript.js +3 -1
  60. package/dist/providers/codex/transcript.js.map +1 -1
  61. package/dist/providers/cursor/discovery.d.ts +6 -0
  62. package/dist/providers/cursor/discovery.d.ts.map +1 -0
  63. package/dist/providers/cursor/discovery.js +118 -0
  64. package/dist/providers/cursor/discovery.js.map +1 -0
  65. package/dist/providers/cursor/execute.d.ts.map +1 -1
  66. package/dist/providers/cursor/execute.js +83 -45
  67. package/dist/providers/cursor/execute.js.map +1 -1
  68. package/dist/providers/cursor/index.d.ts.map +1 -1
  69. package/dist/providers/cursor/index.js +17 -4
  70. package/dist/providers/cursor/index.js.map +1 -1
  71. package/dist/providers/cursor/parse.js +1 -1
  72. package/dist/providers/cursor/parse.js.map +1 -1
  73. package/dist/providers/cursor/probe.d.ts +3 -0
  74. package/dist/providers/cursor/probe.d.ts.map +1 -0
  75. package/dist/providers/cursor/probe.js +89 -0
  76. package/dist/providers/cursor/probe.js.map +1 -0
  77. package/dist/providers/cursor/runtime.d.ts +4 -0
  78. package/dist/providers/cursor/runtime.d.ts.map +1 -0
  79. package/dist/providers/cursor/runtime.js +12 -0
  80. package/dist/providers/cursor/runtime.js.map +1 -0
  81. package/dist/providers/cursor/session.d.ts +3 -0
  82. package/dist/providers/cursor/session.d.ts.map +1 -0
  83. package/dist/providers/cursor/session.js +14 -0
  84. package/dist/providers/cursor/session.js.map +1 -0
  85. package/dist/providers/opencode/client.d.ts +14 -0
  86. package/dist/providers/opencode/client.d.ts.map +1 -0
  87. package/dist/providers/opencode/client.js +40 -0
  88. package/dist/providers/opencode/client.js.map +1 -0
  89. package/dist/providers/opencode/discovery.d.ts +4 -0
  90. package/dist/providers/opencode/discovery.d.ts.map +1 -0
  91. package/dist/providers/opencode/discovery.js +68 -0
  92. package/dist/providers/opencode/discovery.js.map +1 -0
  93. package/dist/providers/opencode/execute.d.ts.map +1 -1
  94. package/dist/providers/opencode/execute.js +219 -164
  95. package/dist/providers/opencode/execute.js.map +1 -1
  96. package/dist/providers/opencode/history.d.ts +24 -0
  97. package/dist/providers/opencode/history.d.ts.map +1 -0
  98. package/dist/providers/opencode/history.js +242 -0
  99. package/dist/providers/opencode/history.js.map +1 -0
  100. package/dist/providers/opencode/http-session.d.ts.map +1 -1
  101. package/dist/providers/opencode/http-session.js +333 -49
  102. package/dist/providers/opencode/http-session.js.map +1 -1
  103. package/dist/providers/opencode/index.d.ts.map +1 -1
  104. package/dist/providers/opencode/index.js +26 -3
  105. package/dist/providers/opencode/index.js.map +1 -1
  106. package/dist/providers/opencode/manager.d.ts +11 -0
  107. package/dist/providers/opencode/manager.d.ts.map +1 -0
  108. package/dist/providers/opencode/manager.js +240 -0
  109. package/dist/providers/opencode/manager.js.map +1 -0
  110. package/dist/providers/opencode/modes.d.ts +3 -0
  111. package/dist/providers/opencode/modes.d.ts.map +1 -0
  112. package/dist/providers/opencode/modes.js +30 -0
  113. package/dist/providers/opencode/modes.js.map +1 -0
  114. package/dist/providers/opencode/parse.d.ts.map +1 -1
  115. package/dist/providers/opencode/parse.js +2 -22
  116. package/dist/providers/opencode/parse.js.map +1 -1
  117. package/dist/providers/opencode/probe.d.ts +3 -0
  118. package/dist/providers/opencode/probe.d.ts.map +1 -0
  119. package/dist/providers/opencode/probe.js +107 -0
  120. package/dist/providers/opencode/probe.js.map +1 -0
  121. package/dist/providers/opencode/runtime.d.ts +8 -0
  122. package/dist/providers/opencode/runtime.d.ts.map +1 -0
  123. package/dist/providers/opencode/runtime.js +12 -0
  124. package/dist/providers/opencode/runtime.js.map +1 -0
  125. package/dist/providers/opencode/server.d.ts +18 -3
  126. package/dist/providers/opencode/server.d.ts.map +1 -1
  127. package/dist/providers/opencode/server.js +0 -0
  128. package/dist/providers/opencode/server.js.map +1 -1
  129. package/dist/providers/opencode/skill-config.d.ts +11 -0
  130. package/dist/providers/opencode/skill-config.d.ts.map +1 -0
  131. package/dist/providers/opencode/skill-config.js +57 -0
  132. package/dist/providers/opencode/skill-config.js.map +1 -0
  133. package/dist/sessions/exec-backed.d.ts +10 -0
  134. package/dist/sessions/exec-backed.d.ts.map +1 -0
  135. package/dist/sessions/exec-backed.js +141 -0
  136. package/dist/sessions/exec-backed.js.map +1 -0
  137. package/dist/sessions/history.d.ts +4 -0
  138. package/dist/sessions/history.d.ts.map +1 -0
  139. package/dist/sessions/history.js +59 -0
  140. package/dist/sessions/history.js.map +1 -0
  141. package/dist/sessions/index.d.ts +3 -0
  142. package/dist/sessions/index.d.ts.map +1 -1
  143. package/dist/sessions/index.js +2 -0
  144. package/dist/sessions/index.js.map +1 -1
  145. package/dist/types.d.ts +152 -6
  146. package/dist/types.d.ts.map +1 -1
  147. package/dist/types.js.map +1 -1
  148. package/dist/utils/auth.d.ts.map +1 -1
  149. package/dist/utils/auth.js +36 -6
  150. package/dist/utils/auth.js.map +1 -1
  151. package/dist/utils/binary.d.ts.map +1 -1
  152. package/dist/utils/binary.js +3 -0
  153. package/dist/utils/binary.js.map +1 -1
  154. package/dist/utils/endpoint.js +3 -3
  155. package/dist/utils/endpoint.js.map +1 -1
  156. package/package.json +10 -2
  157. package/src/derived.ts +126 -3
  158. package/src/goals/controller.ts +17 -0
  159. package/src/goals/normalize.ts +5 -0
  160. package/src/history/fs.ts +307 -0
  161. package/src/history/index.ts +18 -0
  162. package/src/history/types.ts +104 -0
  163. package/src/index.ts +31 -0
  164. package/src/providers/claude/attach.ts +48 -6
  165. package/src/providers/claude/history.ts +290 -0
  166. package/src/providers/claude/index.ts +26 -0
  167. package/src/providers/claude/session.ts +15 -10
  168. package/src/providers/codex/attach.ts +52 -7
  169. package/src/providers/codex/history.ts +399 -0
  170. package/src/providers/codex/index.ts +26 -0
  171. package/src/providers/codex/transcript-normalize.ts +4 -3
  172. package/src/providers/codex/transcript.ts +5 -1
  173. package/src/providers/cursor/discovery.ts +114 -0
  174. package/src/providers/cursor/execute.ts +67 -30
  175. package/src/providers/cursor/index.ts +19 -5
  176. package/src/providers/cursor/parse.ts +1 -1
  177. package/src/providers/cursor/probe.ts +90 -0
  178. package/src/providers/cursor/runtime.ts +11 -0
  179. package/src/providers/cursor/session.ts +15 -0
  180. package/src/providers/opencode/client.ts +44 -0
  181. package/src/providers/opencode/discovery.ts +69 -0
  182. package/src/providers/opencode/execute.ts +64 -12
  183. package/src/providers/opencode/history.ts +270 -0
  184. package/src/providers/opencode/http-session.ts +319 -50
  185. package/src/providers/opencode/index.ts +26 -3
  186. package/src/providers/opencode/manager.ts +269 -0
  187. package/src/providers/opencode/modes.ts +31 -0
  188. package/src/providers/opencode/parse.ts +2 -22
  189. package/src/providers/opencode/probe.ts +113 -0
  190. package/src/providers/opencode/runtime.ts +13 -0
  191. package/src/providers/opencode/server.ts +0 -0
  192. package/src/providers/opencode/skill-config.ts +64 -0
  193. package/src/sessions/exec-backed.ts +169 -0
  194. package/src/sessions/history.ts +76 -0
  195. package/src/sessions/index.ts +3 -0
  196. package/src/types.ts +163 -4
  197. package/src/utils/auth.ts +35 -6
  198. package/src/utils/binary.ts +3 -0
  199. package/src/utils/endpoint.ts +3 -3
@@ -16,6 +16,7 @@ import {
16
16
  import { getRuntimeHomeEnvVar } from "../../utils/runtime-homes.js";
17
17
  import { codexSessionCodec } from "./codec.js";
18
18
  import {
19
+ type CodexTranscriptLine,
19
20
  getCodexTranscriptPath,
20
21
  peekCodexTranscript,
21
22
  readCodexCwd,
@@ -37,17 +38,61 @@ const EMPTY: AsyncIterable<CatchUpYield> = {
37
38
  },
38
39
  };
39
40
 
41
+ function isTurnBoundary(line: CodexTranscriptLine, sessionId: string): boolean {
42
+ const payloadType = typeof line.payload?.["type"] === "string" ? line.payload["type"] : null;
43
+
44
+ // These raw records start a turn but intentionally normalize to no replay
45
+ // event. They must still supersede an earlier task_complete when deciding
46
+ // whether the latest persisted turn was interrupted.
47
+ if (line.type === "event_msg" && (payloadType === "task_started" || payloadType === "user_message")) {
48
+ return true;
49
+ }
50
+ if (line.type === "response_item" && payloadType === "message" && line.payload?.["role"] === "user") {
51
+ return true;
52
+ }
53
+ if (line.type === "message" && line.raw["role"] === "user") return true;
54
+
55
+ return codexLineToStreamEvents(line, { sessionId }).some((event) => ![
56
+ "system",
57
+ "permission_mode",
58
+ "rate_limit",
59
+ "goal_status",
60
+ "unknown",
61
+ ].includes(event.type));
62
+ }
63
+
64
+ async function latestTurnBoundary(
65
+ filePath: string,
66
+ sessionId: string,
67
+ ): Promise<CodexTranscriptLine | null> {
68
+ const { lastEvent } = await peekCodexTranscript(filePath, {
69
+ accept: (line) => isTurnBoundary(line, sessionId),
70
+ });
71
+ if (lastEvent) return lastEvent;
72
+ let latest: CodexTranscriptLine | null = null;
73
+ for await (const { event } of readCodexTranscript({ filePath })) {
74
+ if (isTurnBoundary(event, sessionId)) latest = event;
75
+ }
76
+ return latest;
77
+ }
78
+
40
79
  /**
41
80
  * Read-only reattachment to a durable Codex session. Same skeleton as Claude,
42
81
  * with two deltas: classify via the rollout's last line, and replay through
43
- * `codexLineToStreamEvents` (Codex has no wire ids, so `eventId` is always null
44
- * — hosts gate replay dedup on their own running flag, per spec §9.7).
82
+ * `codexLineToStreamEvents`. File-backed transcript lines carry deterministic
83
+ * synthetic event ids so replay can be deduplicated across process restarts.
45
84
  */
46
85
  export async function attachCodexSession(
47
86
  record: SessionRecord,
48
87
  opts?: AttachOptions,
49
88
  ): Promise<SessionAttachment> {
50
89
  assertSessionRecord(record);
90
+ if (record.providerType !== "codex") {
91
+ throw new MalformedSessionRecordError(
92
+ `codex attach requires providerType "codex"; got ${JSON.stringify(record.providerType)}`,
93
+ "providerType",
94
+ );
95
+ }
51
96
 
52
97
  // 1. Normalize params through the codec.
53
98
  const params = codexSessionCodec.deserialize(record.params);
@@ -85,9 +130,9 @@ export async function attachCodexSession(
85
130
  // 3. Classify how the last persisted turn ended.
86
131
  let lastTurn: LastTurnStatus = "unknown";
87
132
  if (transcript) {
88
- const { lastEvent } = await peekCodexTranscript(transcript.filePath);
133
+ const lastEvent = await latestTurnBoundary(transcript.filePath, sessionId);
89
134
  if (lastEvent === null) lastTurn = "unknown";
90
- else if (lastEvent.type === "event_msg" && lastEvent.payload?.["type"] === "task_complete")
135
+ else if (codexLineToStreamEvents(lastEvent, { sessionId }).some((event) => event.type === "result"))
91
136
  lastTurn = "completed";
92
137
  else lastTurn = "interrupted";
93
138
  }
@@ -97,7 +142,7 @@ export async function attachCodexSession(
97
142
  transcript,
98
143
  lastTurn,
99
144
  // 4. Replay: map each rollout line through the normalizer; one yield per
100
- // produced event, all sharing the line's offset, eventId always null.
145
+ // produced event, all sharing the line's offset and source event id.
101
146
  catchUp(catchOpts?: CatchUpOptions): AsyncIterable<CatchUpYield> {
102
147
  if (!transcript) return EMPTY;
103
148
  const filePath = transcript.filePath;
@@ -109,7 +154,7 @@ export async function attachCodexSession(
109
154
  ...(catchOpts?.fromOffset !== undefined ? { fromOffset: catchOpts.fromOffset } : {}),
110
155
  })) {
111
156
  for (const event of codexLineToStreamEvents(line, { sessionId: sid })) {
112
- yield { event, offset, eventId: null };
157
+ yield { event, offset, eventId: event.eventId };
113
158
  }
114
159
  }
115
160
  },
@@ -117,7 +162,7 @@ export async function attachCodexSession(
117
162
  },
118
163
  // 5. Continue live — exactly `createSession` with the record's params.
119
164
  resume(ctx?: SessionContext) {
120
- return createCodexSession({ ...ctx, sessionParams: params });
165
+ return createCodexSession({ ...ctx, cwd: ctx?.cwd ?? cwd ?? undefined, sessionParams: params });
121
166
  },
122
167
  };
123
168
  }
@@ -0,0 +1,399 @@
1
+ import * as path from "node:path";
2
+ import { readdir } from "node:fs/promises";
3
+
4
+ import type { BaseStreamEventFields, StreamEvent } from "../../types.js";
5
+ import type {
6
+ LocalHistoryDiscoverOptions,
7
+ LocalHistoryOps,
8
+ LocalHistoryProbeOptions,
9
+ LocalHistorySession,
10
+ LocalHistoryYield,
11
+ } from "../../history/types.js";
12
+ import {
13
+ ROLLOUT_UUID_RE,
14
+ asRecord,
15
+ asString,
16
+ cleanTitle,
17
+ isDirectory,
18
+ isoDate,
19
+ listJsonlFiles,
20
+ meaningfulHumanText,
21
+ readJsonlRecords,
22
+ recentEligible,
23
+ sourceFingerprint,
24
+ textFromContent,
25
+ } from "../../history/fs.js";
26
+ import { codexLineToStreamEvents } from "./transcript-normalize.js";
27
+ import { parseCodexLine, resolveCodexHome, type CodexTranscriptLine } from "./transcript.js";
28
+
29
+ const MAX_DISCOVERY_SCAN_BYTES = 4 * 1024 * 1024;
30
+ const DEFAULT_PROBE_LIMIT = 100;
31
+ const DISCOVERY_CONCURRENCY = 16;
32
+
33
+ function codexHome(options?: { env?: Record<string, string> }): string {
34
+ return resolveCodexHome(options?.env?.["CODEX_HOME"]);
35
+ }
36
+
37
+ async function listCodexSessionFiles(
38
+ home: string,
39
+ options: { includeArchived: boolean; limit?: number },
40
+ ): Promise<string[]> {
41
+ const cap = Math.max(1, options.limit ?? Number.MAX_SAFE_INTEGER);
42
+ const active = await listJsonlFiles(path.join(home, "sessions"), {
43
+ maxDepth: 5,
44
+ limit: cap,
45
+ accept: (name) => ROLLOUT_UUID_RE.test(name),
46
+ });
47
+ if (!options.includeArchived || active.length >= cap) return active;
48
+ const archived = await listJsonlFiles(path.join(home, "archived_sessions"), {
49
+ maxDepth: 2,
50
+ limit: cap - active.length,
51
+ accept: (name) => ROLLOUT_UUID_RE.test(name),
52
+ });
53
+ return [...active, ...archived];
54
+ }
55
+
56
+ function codexUserText(line: CodexTranscriptLine): string | null {
57
+ if (line.type === "event_msg" && line.payload?.["type"] === "user_message") {
58
+ return meaningfulHumanText(asString(line.payload["message"]));
59
+ }
60
+ // Older unwrapped rollouts have no event_msg mirror.
61
+ if (line.type === "message" && line.raw["role"] === "user") {
62
+ return meaningfulHumanText(textFromContent(
63
+ line.raw["content"],
64
+ new Set(["text", "input_text"]),
65
+ ));
66
+ }
67
+ return null;
68
+ }
69
+
70
+ function isSubagentMeta(payload: Record<string, unknown>): boolean {
71
+ if (asString(payload["parent_thread_id"])) return true;
72
+ if (payload["thread_source"] === "subagent") return true;
73
+ const source = asRecord(payload["source"]);
74
+ return !!source && source["subagent"] !== undefined;
75
+ }
76
+
77
+ function legacyCwd(record: Record<string, unknown>): string | null {
78
+ if (record["type"] !== "message" || record["role"] !== "user") return null;
79
+ const text = textFromContent(record["content"], new Set(["text", "input_text"]));
80
+ const xml = text?.match(/<cwd>([^<]+)<\/cwd>/)?.[1];
81
+ const plain = text?.match(/Current working directory:\s*([^\n\r]+)/)?.[1];
82
+ const candidate = (xml ?? plain)?.trim();
83
+ return candidate && path.isAbsolute(candidate) ? candidate : null;
84
+ }
85
+
86
+ async function loadSessionIndexTitles(home: string): Promise<Map<string, string>> {
87
+ const titles = new Map<string, string>();
88
+ try {
89
+ for await (const line of readJsonlRecords(path.join(home, "session_index.jsonl"))) {
90
+ const id = asString(line.raw["id"]);
91
+ const title = cleanTitle(asString(line.raw["thread_name"]));
92
+ if (id && title) titles.set(id, title);
93
+ }
94
+ } catch {
95
+ // The index is optional and absent on older Codex versions.
96
+ }
97
+ return titles;
98
+ }
99
+
100
+ interface SqliteDatabase {
101
+ prepare(sql: string): { all(): unknown[] };
102
+ close(): void;
103
+ }
104
+
105
+ async function stateDatabasePaths(home: string): Promise<string[]> {
106
+ const paths: string[] = [];
107
+ for (const dir of [home, path.join(home, "sqlite")]) {
108
+ let entries;
109
+ try {
110
+ entries = await readdir(dir, { withFileTypes: true });
111
+ } catch {
112
+ continue;
113
+ }
114
+ for (const entry of entries) {
115
+ if (entry.isFile() && /^state_\d+\.sqlite$/.test(entry.name)) paths.push(path.join(dir, entry.name));
116
+ }
117
+ }
118
+ return paths.sort().reverse();
119
+ }
120
+
121
+ async function loadSqliteTitles(home: string): Promise<Map<string, string>> {
122
+ const titles = new Map<string, string>();
123
+ try {
124
+ // Keep Node 20 compatibility. node:sqlite is used opportunistically on
125
+ // runtimes that provide it and is always opened read-only.
126
+ const moduleName = "node:sqlite";
127
+ const sqlite = await import(moduleName) as unknown as {
128
+ DatabaseSync: new (filePath: string, options: { readOnly: boolean }) => SqliteDatabase;
129
+ };
130
+ for (const filePath of await stateDatabasePaths(home)) {
131
+ let database: SqliteDatabase | null = null;
132
+ try {
133
+ database = new sqlite.DatabaseSync(filePath, { readOnly: true });
134
+ for (const value of database.prepare("SELECT id, title FROM threads WHERE title <> ''").all()) {
135
+ const row = asRecord(value);
136
+ const id = asString(row?.["id"]);
137
+ const title = cleanTitle(asString(row?.["title"]));
138
+ if (id && title && !titles.has(id)) titles.set(id, title);
139
+ }
140
+ if (titles.size > 0) break;
141
+ } catch {
142
+ // Missing, locked, or schema-incompatible indexes are non-fatal.
143
+ } finally {
144
+ database?.close();
145
+ }
146
+ }
147
+ } catch {
148
+ // node:sqlite is unavailable on Node 20.
149
+ }
150
+ return titles;
151
+ }
152
+
153
+ async function loadCodexTitles(home: string): Promise<Map<string, string>> {
154
+ const index = await loadSessionIndexTitles(home);
155
+ // session_index is the least invasive and most portable source. SQLite is
156
+ // a fallback for installations that do not maintain the JSONL index.
157
+ return index.size > 0 ? index : loadSqliteTitles(home);
158
+ }
159
+
160
+ async function inspectCodexSession(
161
+ transcriptPath: string,
162
+ titles: ReadonlyMap<string, string>,
163
+ options: LocalHistoryDiscoverOptions,
164
+ ): Promise<LocalHistorySession | null> {
165
+ let meta: Record<string, unknown> | null = null;
166
+ let legacyMeta: Record<string, unknown> | null = null;
167
+ let metaTimestamp: string | null = null;
168
+ let firstPrompt: string | null = null;
169
+ let fallbackCwd: string | null = null;
170
+
171
+ for await (const record of readJsonlRecords(transcriptPath)) {
172
+ const line = parseCodexLine(record.text);
173
+ if (!line) continue;
174
+ if (line.type === "session_meta" && line.payload) {
175
+ meta ??= line.payload;
176
+ metaTimestamp ??= line.timestamp;
177
+ }
178
+ if (line.type === null && asString(line.raw["id"])) legacyMeta ??= line.raw;
179
+ firstPrompt ??= codexUserText(line);
180
+ fallbackCwd ??= legacyCwd(line.raw);
181
+ if (meta && firstPrompt) break;
182
+ if (record.nextOffset >= MAX_DISCOVERY_SCAN_BYTES) break;
183
+ }
184
+
185
+ const fileId = path.basename(transcriptPath).match(ROLLOUT_UUID_RE)?.[1] ?? null;
186
+ const externalSessionId = asString(meta?.["id"])
187
+ ?? asString(meta?.["session_id"])
188
+ ?? asString(legacyMeta?.["id"])
189
+ ?? fileId;
190
+ if (!externalSessionId) return null;
191
+ if ((options.mainSessionsOnly ?? true) && meta && isSubagentMeta(meta)) return null;
192
+
193
+ const metaCwd = asString(meta?.["cwd"]);
194
+ const cwd = metaCwd && path.isAbsolute(metaCwd) ? metaCwd : fallbackCwd;
195
+ if (!cwd || (options.cwd && path.normalize(cwd) !== path.normalize(options.cwd))) return null;
196
+ const hasUserMessage = firstPrompt !== null;
197
+ if ((options.requireUserMessage ?? true) && !hasUserMessage) return null;
198
+
199
+ const git = asRecord(meta?.["git"]);
200
+ const source = await sourceFingerprint(transcriptPath);
201
+ const archiveState = transcriptPath.split(path.sep).includes("archived_sessions")
202
+ ? "archived" as const
203
+ : "active" as const;
204
+ return {
205
+ version: 1,
206
+ providerType: "codex",
207
+ externalSessionId,
208
+ transcriptPath,
209
+ cwd,
210
+ title: titles.get(externalSessionId) ?? cleanTitle(firstPrompt),
211
+ startedAt: isoDate(meta?.["timestamp"] ?? metaTimestamp ?? legacyMeta?.["timestamp"]),
212
+ updatedAt: new Date(Number(BigInt(source.modifiedAtNs) / 1_000_000n)).toISOString(),
213
+ branch: asString(git?.["branch"]) ?? asString(meta?.["git_branch"]),
214
+ gitOriginUrl: asString(git?.["repository_url"])
215
+ ?? asString(git?.["origin_url"])
216
+ ?? asString(meta?.["git_origin_url"]),
217
+ archiveState,
218
+ hasUserMessage,
219
+ source,
220
+ };
221
+ }
222
+
223
+ function baseFields(
224
+ session: LocalHistorySession,
225
+ line: CodexTranscriptLine,
226
+ eventId: string,
227
+ ): BaseStreamEventFields & { eventId: string } {
228
+ return {
229
+ timestamp: isoDate(line.timestamp, session.startedAt ?? new Date(0).toISOString())!,
230
+ providerType: "codex",
231
+ sessionId: session.externalSessionId,
232
+ messageId: null,
233
+ eventId,
234
+ turnId: null,
235
+ parentToolCallId: null,
236
+ raw: line.raw,
237
+ };
238
+ }
239
+
240
+ function legacyCodexEvents(
241
+ session: LocalHistorySession,
242
+ line: CodexTranscriptLine,
243
+ eventId: string,
244
+ ): Array<StreamEvent & { eventId: string }> {
245
+ if (line.payload) return [];
246
+ const base = baseFields(session, line, eventId);
247
+ if (line.type === "message" && line.raw["role"] === "assistant") {
248
+ const text = textFromContent(line.raw["content"], new Set(["text", "output_text"]));
249
+ return text ? [{ type: "assistant", text, ...base }] : [];
250
+ }
251
+ if (line.type === "reasoning") {
252
+ const text = textFromContent(line.raw["summary"] ?? line.raw["content"], new Set(["text", "summary_text"]));
253
+ return text ? [{ type: "thinking", text, ...base }] : [];
254
+ }
255
+ if (line.type === "function_call") {
256
+ const toolCallId = asString(line.raw["call_id"]) ?? asString(line.raw["id"]);
257
+ const name = asString(line.raw["name"]) ?? "function_call";
258
+ const rawArguments = line.raw["arguments"];
259
+ let input: Record<string, unknown> | string | null = asString(rawArguments);
260
+ if (typeof rawArguments === "string") {
261
+ try {
262
+ const parsed = JSON.parse(rawArguments);
263
+ if (asRecord(parsed)) input = parsed as Record<string, unknown>;
264
+ } catch {
265
+ // Preserve malformed arguments as their original string.
266
+ }
267
+ } else if (asRecord(rawArguments)) {
268
+ input = rawArguments as Record<string, unknown>;
269
+ }
270
+ return [{ type: "tool_call", toolCallId, name, input, ...base }];
271
+ }
272
+ if (line.type === "function_call_output") {
273
+ const output = line.raw["output"];
274
+ let content = typeof output === "string" ? output : "";
275
+ if (!content && output !== undefined) {
276
+ try {
277
+ content = JSON.stringify(output);
278
+ } catch {
279
+ content = "";
280
+ }
281
+ }
282
+ return [{
283
+ type: "tool_result",
284
+ toolCallId: asString(line.raw["call_id"]),
285
+ toolName: null,
286
+ content,
287
+ isError: line.raw["is_error"] === true,
288
+ exitCode: typeof line.raw["exit_code"] === "number" ? line.raw["exit_code"] : null,
289
+ ...base,
290
+ }];
291
+ }
292
+ return [];
293
+ }
294
+
295
+ function dedupeCodexSessionFiles(filePaths: readonly string[]): string[] {
296
+ const seen = new Set<string>();
297
+ const output: string[] = [];
298
+ // listCodexSessionFiles returns active files first, so an active copy wins
299
+ // over a briefly duplicated archive copy before transcript inspection.
300
+ for (const filePath of filePaths) {
301
+ const id = path.basename(filePath).match(ROLLOUT_UUID_RE)?.[1] ?? filePath;
302
+ if (seen.has(id)) continue;
303
+ seen.add(id);
304
+ output.push(filePath);
305
+ }
306
+ return output;
307
+ }
308
+
309
+ function normalizedCodexEvents(
310
+ session: LocalHistorySession,
311
+ line: CodexTranscriptLine,
312
+ eventId: string,
313
+ ): Array<StreamEvent & { eventId: string }> {
314
+ const events = line.payload
315
+ ? codexLineToStreamEvents(line, { sessionId: session.externalSessionId })
316
+ : legacyCodexEvents(session, line, eventId);
317
+ return events
318
+ .filter((event) => !((event.type === "assistant" || event.type === "thinking") && !event.text.trim()))
319
+ .map((event) => ({
320
+ ...event,
321
+ timestamp: isoDate(line.timestamp, event.timestamp)!,
322
+ providerType: "codex",
323
+ sessionId: session.externalSessionId,
324
+ eventId,
325
+ raw: line.raw,
326
+ } as StreamEvent & { eventId: string }));
327
+ }
328
+
329
+ export const codexLocalHistory: LocalHistoryOps = {
330
+ async probe(options?: LocalHistoryProbeOptions) {
331
+ const home = codexHome(options);
332
+ const homeAvailable = await isDirectory(home);
333
+ const files = homeAvailable
334
+ ? await listCodexSessionFiles(home, {
335
+ includeArchived: true,
336
+ limit: options?.limit ?? DEFAULT_PROBE_LIMIT,
337
+ })
338
+ : [];
339
+ return {
340
+ providerType: "codex",
341
+ homeAvailable,
342
+ historyAvailable: files.length > 0,
343
+ approximateCount: files.length,
344
+ };
345
+ },
346
+
347
+ async *discover(options: LocalHistoryDiscoverOptions = {}) {
348
+ const home = codexHome(options);
349
+ const [files, titles] = await Promise.all([
350
+ listCodexSessionFiles(home, { includeArchived: options.includeArchived ?? true }),
351
+ loadCodexTitles(home),
352
+ ]);
353
+ const sessions = await recentEligible(
354
+ dedupeCodexSessionFiles(files),
355
+ { limit: options.limit, concurrency: DISCOVERY_CONCURRENCY },
356
+ async (filePath) => {
357
+ try {
358
+ return await inspectCodexSession(filePath, titles, options);
359
+ } catch {
360
+ return null;
361
+ }
362
+ },
363
+ );
364
+ for (const session of sessions) yield session;
365
+ },
366
+
367
+ async *read(session, options = {}): AsyncIterable<LocalHistoryYield> {
368
+ if (session.providerType !== "codex" || !path.isAbsolute(session.transcriptPath)) {
369
+ throw new Error("Invalid Codex local history session");
370
+ }
371
+ for await (const record of readJsonlRecords(
372
+ session.transcriptPath,
373
+ options.fromOffset ?? 0,
374
+ { rejectChanges: true },
375
+ )) {
376
+ const line = parseCodexLine(record.text);
377
+ if (!line) continue;
378
+ const eventId = `codex:${session.externalSessionId}:${record.lineStartOffset}`;
379
+ line.eventId = eventId;
380
+ const events: LocalHistoryYield["event"][] = [];
381
+ const userText = codexUserText(line);
382
+ if (userText) events.push({ type: "user", text: userText, ...baseFields(session, line, eventId) });
383
+ events.push(...normalizedCodexEvents(session, line, eventId));
384
+ for (let partIndex = 0; partIndex < events.length; partIndex++) {
385
+ yield {
386
+ event: events[partIndex]!,
387
+ lineStartOffset: record.lineStartOffset,
388
+ nextOffset: record.nextOffset,
389
+ partIndex,
390
+ };
391
+ }
392
+ }
393
+ },
394
+
395
+ fingerprint(session, options) {
396
+ if (session.providerType !== "codex") throw new Error("Invalid Codex local history session");
397
+ return sourceFingerprint(session.transcriptPath, options?.sha256 ?? false);
398
+ },
399
+ };
@@ -23,6 +23,8 @@ export const codexProvider: ProviderModule = {
23
23
  modes: true,
24
24
  goals: codexGoalCapability,
25
25
  durableSessions: true,
26
+ durableHistory: true,
27
+ localHistory: true,
26
28
  },
27
29
  // Heavy machinery (execute.ts, session.ts, modes.ts, attach.ts) loads lazily
28
30
  // on first use — every ProviderModule method is already async.
@@ -35,6 +37,30 @@ export const codexProvider: ProviderModule = {
35
37
  listModes: async (opts) => (await import("./modes.js")).listCodexModes(opts),
36
38
  attachSession: async (record, opts) =>
37
39
  (await import("./attach.js")).attachCodexSession(record, opts),
40
+ attachHistory: async (record, opts) => {
41
+ const [{ attachCodexSession }, { historyFromSessionAttachment }] = await Promise.all([
42
+ import("./attach.js"),
43
+ import("../../sessions/history.js"),
44
+ ]);
45
+ return historyFromSessionAttachment("codex", await attachCodexSession(record, opts));
46
+ },
47
+ localHistory: {
48
+ probe: (options) => import("./history.js").then((module) => module.codexLocalHistory.probe(options)),
49
+ discover: (options) => ({
50
+ async *[Symbol.asyncIterator]() {
51
+ const module = await import("./history.js");
52
+ yield* module.codexLocalHistory.discover(options);
53
+ },
54
+ }),
55
+ read: (session, options) => ({
56
+ async *[Symbol.asyncIterator]() {
57
+ const module = await import("./history.js");
58
+ yield* module.codexLocalHistory.read(session, options);
59
+ },
60
+ }),
61
+ fingerprint: (session, options) => import("./history.js")
62
+ .then((module) => module.codexLocalHistory.fingerprint(session, options)),
63
+ },
38
64
  };
39
65
 
40
66
  export {
@@ -20,8 +20,9 @@ import type { CodexTranscriptLine } from "./transcript.js";
20
20
  *
21
21
  * The on-disk vocabulary is Codex-internal and version-shifting, so every field
22
22
  * is read defensively and a weird line NEVER throws — it returns `[]`. Codex
23
- * emits no per-line wire id, so `eventId`/`turnId` are null (hosts gate replay
24
- * dedup on their own "not currently running" flag; see spec §9.7).
23
+ * emits no native per-line wire id. File readers attach a deterministic
24
+ * synthetic `line.eventId`, which this normalizer preserves. Standalone line
25
+ * parsing still produces a null event id.
25
26
  */
26
27
  export function codexLineToStreamEvents(
27
28
  line: CodexTranscriptLine,
@@ -47,7 +48,7 @@ function mapLine(line: CodexTranscriptLine, sessionId: string | null): StreamEve
47
48
  providerType: "codex",
48
49
  sessionId,
49
50
  messageId: null,
50
- eventId: null,
51
+ eventId: line.eventId,
51
52
  turnId: null,
52
53
  parentToolCallId: null,
53
54
  raw: line.raw,
@@ -415,7 +415,10 @@ export interface CodexPeekResult {
415
415
  * Cheap drift-check: reads up to {@link PEEK_TAIL_BYTES} from the tail,
416
416
  * walks back to the last parseable line, returns it plus the file size.
417
417
  */
418
- export async function peekCodexTranscript(filePath: string): Promise<CodexPeekResult> {
418
+ export async function peekCodexTranscript(
419
+ filePath: string,
420
+ options: { accept?: (event: CodexTranscriptLine) => boolean } = {},
421
+ ): Promise<CodexPeekResult> {
419
422
  let size: number;
420
423
  try {
421
424
  const s = await stat(filePath);
@@ -452,6 +455,7 @@ export async function peekCodexTranscript(filePath: string): Promise<CodexPeekRe
452
455
  if (!trimmed) continue;
453
456
  const parsed = parseCodexLine(trimmed);
454
457
  if (!parsed) continue;
458
+ if (options.accept && !options.accept(parsed)) continue;
455
459
  return { lastEvent: parsed, size };
456
460
  }
457
461