@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
@@ -0,0 +1,104 @@
1
+ import type { BaseStreamEventFields, StreamEvent } from "../types.js";
2
+
3
+ export interface LocalHistoryDiscoverOptions {
4
+ includeArchived?: boolean;
5
+ mainSessionsOnly?: boolean;
6
+ requireUserMessage?: boolean;
7
+ cwd?: string;
8
+ limit?: number;
9
+ env?: Record<string, string>;
10
+ }
11
+
12
+ export interface LocalHistoryProbeOptions {
13
+ limit?: number;
14
+ env?: Record<string, string>;
15
+ }
16
+
17
+ export interface LocalHistoryProbeResult {
18
+ providerType: string;
19
+ homeAvailable: boolean;
20
+ historyAvailable: boolean;
21
+ /** A file count only. It is not an eligible-session count. */
22
+ approximateCount?: number;
23
+ }
24
+
25
+ export type LocalHistoryArchiveState = "active" | "archived" | "unknown";
26
+
27
+ export interface LocalHistorySourceFingerprint {
28
+ size: number;
29
+ /** String because nanosecond timestamps can exceed Number.MAX_SAFE_INTEGER. */
30
+ modifiedAtNs: string;
31
+ sha256?: string;
32
+ }
33
+
34
+ export interface LocalHistorySession {
35
+ version: 1;
36
+ providerType: string;
37
+ externalSessionId: string;
38
+ /** Local-runtime detail. Network hosts must not serialize this to clients. */
39
+ transcriptPath: string;
40
+ cwd: string | null;
41
+ title: string | null;
42
+ startedAt: string | null;
43
+ updatedAt: string;
44
+ branch: string | null;
45
+ gitOriginUrl: string | null;
46
+ archiveState: LocalHistoryArchiveState;
47
+ hasUserMessage: boolean;
48
+ source: LocalHistorySourceFingerprint;
49
+ }
50
+
51
+ export type LocalHistoryUserEvent = {
52
+ type: "user";
53
+ text: string;
54
+ } & BaseStreamEventFields;
55
+
56
+ export type LocalHistoryEvent = StreamEvent | LocalHistoryUserEvent;
57
+
58
+ export interface LocalHistoryYield {
59
+ event: LocalHistoryEvent & { eventId: string };
60
+ lineStartOffset: number;
61
+ nextOffset: number;
62
+ /** Disambiguates several normalized events produced from one source record. */
63
+ partIndex: number;
64
+ }
65
+
66
+ export interface LocalHistoryReadOptions {
67
+ fromOffset?: number;
68
+ }
69
+
70
+ export interface LocalHistoryFingerprintOptions {
71
+ sha256?: boolean;
72
+ }
73
+
74
+ export type LocalHistoryErrorCode =
75
+ | "home_missing"
76
+ | "permission_denied"
77
+ | "source_missing"
78
+ | "unsupported_format"
79
+ | "source_changed_during_read"
80
+ | "invalid_session"
81
+ | "io_error";
82
+
83
+ export class LocalHistoryError extends Error {
84
+ readonly code: LocalHistoryErrorCode;
85
+
86
+ constructor(code: LocalHistoryErrorCode, message: string, options?: ErrorOptions) {
87
+ super(message, options);
88
+ this.name = "LocalHistoryError";
89
+ this.code = code;
90
+ }
91
+ }
92
+
93
+ export interface LocalHistoryOps {
94
+ probe(options?: LocalHistoryProbeOptions): Promise<LocalHistoryProbeResult>;
95
+ discover(options?: LocalHistoryDiscoverOptions): AsyncIterable<LocalHistorySession>;
96
+ read(
97
+ session: LocalHistorySession,
98
+ options?: LocalHistoryReadOptions,
99
+ ): AsyncIterable<LocalHistoryYield>;
100
+ fingerprint(
101
+ session: LocalHistorySession,
102
+ options?: LocalHistoryFingerprintOptions,
103
+ ): Promise<LocalHistorySourceFingerprint>;
104
+ }
package/src/index.ts CHANGED
@@ -141,6 +141,9 @@ export {
141
141
  export type {
142
142
  ProviderModule,
143
143
  ProviderCapabilities,
144
+ ProviderRuntimeContext,
145
+ ProviderRuntimeReport,
146
+ CapabilityStatus,
144
147
  AgentMode,
145
148
  ListModesOptions,
146
149
  ExecutionContext,
@@ -168,6 +171,11 @@ export type {
168
171
  AuthRequiredReason,
169
172
  BinaryStatus,
170
173
  ProviderModel,
174
+ ListModelsOptions,
175
+ ProviderAuthMethod,
176
+ ProviderAuthFlow,
177
+ UpstreamProvider,
178
+ UpstreamProviderManager,
171
179
  SessionContext,
172
180
  AgentSession,
173
181
  SendHandle,
@@ -198,6 +206,24 @@ export type {
198
206
  GoalSentinelContext,
199
207
  } from "./types.js";
200
208
 
209
+ // Local persisted session discovery. Runtime errors are exported from the
210
+ // `@agentex/agent/history` subpath to keep the root barrel lazy.
211
+ export type {
212
+ LocalHistoryArchiveState,
213
+ LocalHistoryDiscoverOptions,
214
+ LocalHistoryErrorCode,
215
+ LocalHistoryEvent,
216
+ LocalHistoryFingerprintOptions,
217
+ LocalHistoryOps,
218
+ LocalHistoryProbeOptions,
219
+ LocalHistoryProbeResult,
220
+ LocalHistoryReadOptions,
221
+ LocalHistorySession,
222
+ LocalHistorySourceFingerprint,
223
+ LocalHistoryUserEvent,
224
+ LocalHistoryYield,
225
+ } from "./history/index.js";
226
+
201
227
  // Goals
202
228
  export {
203
229
  GoalController,
@@ -235,6 +261,11 @@ export type {
235
261
  CatchUpOptions,
236
262
  AttachOptions,
237
263
  SessionAttachment,
264
+ HistoryCheckpoint,
265
+ HistorySource,
266
+ HistoryCatchUpYield,
267
+ HistoryCatchUpOptions,
268
+ HistoryAttachment,
238
269
  } from "./types.js";
239
270
 
240
271
  export type {
@@ -1,4 +1,6 @@
1
+ import { createReadStream } from "node:fs";
1
2
  import { stat } from "node:fs/promises";
3
+ import * as readline from "node:readline";
2
4
  import type {
3
5
  AttachOptions,
4
6
  CatchUpOptions,
@@ -19,7 +21,6 @@ import { claudeSessionCodec } from "./codec.js";
19
21
  import {
20
22
  findClaudeTranscriptBySessionId,
21
23
  getClaudeTranscriptPath,
22
- peekClaudeTranscript,
23
24
  readClaudeTranscript,
24
25
  } from "./transcript.js";
25
26
  // Heavy-on-heavy is fine: this whole module loads lazily via
@@ -77,6 +78,44 @@ const EMPTY: AsyncIterable<CatchUpYield> = {
77
78
  },
78
79
  };
79
80
 
81
+ /**
82
+ * Classify lifecycle boundaries from the raw transcript rather than replay's
83
+ * normalized events. Ordinary Claude `user` prompt records intentionally
84
+ * normalize to no StreamEvent, but still start a new turn. Looking only at
85
+ * normalized output can therefore mistake an interrupted new turn for the
86
+ * preceding completed one.
87
+ */
88
+ async function classifyLastTurn(filePath: string): Promise<LastTurnStatus> {
89
+ const stream = createReadStream(filePath, { encoding: "utf8" });
90
+ stream.on("error", () => {});
91
+ const lines = readline.createInterface({ input: stream, crlfDelay: Infinity });
92
+ let lastTurn: LastTurnStatus = "unknown";
93
+
94
+ try {
95
+ for await (const line of lines) {
96
+ let raw: unknown;
97
+ try {
98
+ raw = JSON.parse(line);
99
+ } catch {
100
+ continue;
101
+ }
102
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) continue;
103
+ const type = (raw as Record<string, unknown>)["type"];
104
+ if (type === "result") lastTurn = "completed";
105
+ else if (type === "user" || type === "assistant") lastTurn = "interrupted";
106
+ }
107
+ } catch (error) {
108
+ const fsError = error as NodeJS.ErrnoException;
109
+ if (fsError?.code !== "ENOENT") throw error;
110
+ return "unknown";
111
+ } finally {
112
+ lines.close();
113
+ stream.destroy();
114
+ }
115
+
116
+ return lastTurn;
117
+ }
118
+
80
119
  /**
81
120
  * Read-only reattachment to a durable Claude session. Composition of existing
82
121
  * primitives (codec + transcript ops + `createClaudeSession` resume) — spawns
@@ -87,6 +126,12 @@ export async function attachClaudeSession(
87
126
  opts?: AttachOptions,
88
127
  ): Promise<SessionAttachment> {
89
128
  assertSessionRecord(record);
129
+ if (record.providerType !== "claude") {
130
+ throw new MalformedSessionRecordError(
131
+ `claude attach requires providerType "claude"; got ${JSON.stringify(record.providerType)}`,
132
+ "providerType",
133
+ );
134
+ }
90
135
 
91
136
  // 1. Normalize params through the codec.
92
137
  const params = claudeSessionCodec.deserialize(record.params);
@@ -113,10 +158,7 @@ export async function attachClaudeSession(
113
158
  // 3. Classify how the last persisted turn ended.
114
159
  let lastTurn: LastTurnStatus = "unknown";
115
160
  if (transcript) {
116
- const { lastEvent } = await peekClaudeTranscript(transcript.filePath);
117
- if (lastEvent === null) lastTurn = "unknown";
118
- else if (lastEvent.type === "result") lastTurn = "completed";
119
- else lastTurn = "interrupted";
161
+ lastTurn = await classifyLastTurn(transcript.filePath);
120
162
  }
121
163
 
122
164
  return {
@@ -141,7 +183,7 @@ export async function attachClaudeSession(
141
183
  },
142
184
  // 5. Continue live — exactly `createSession` with the record's params.
143
185
  resume(ctx?: SessionContext) {
144
- return createClaudeSession({ ...ctx, sessionParams: params });
186
+ return createClaudeSession({ ...ctx, cwd: ctx?.cwd ?? cwd ?? undefined, sessionParams: params });
145
187
  },
146
188
  };
147
189
  }
@@ -0,0 +1,290 @@
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
+ UUID_JSONL_RE,
14
+ asRecord,
15
+ asString,
16
+ cleanTitle,
17
+ isDirectory,
18
+ isoDate,
19
+ meaningfulHumanText,
20
+ readJsonlRecords,
21
+ recentEligible,
22
+ sourceFingerprint,
23
+ textFromContent,
24
+ } from "../../history/fs.js";
25
+ import { parseStreamLine } from "./parse.js";
26
+ import { resolveClaudeHome } from "./transcript.js";
27
+
28
+ const TITLE_SCAN_BYTES = 64 * 1024;
29
+ const MAX_DISCOVERY_SCAN_BYTES = 4 * 1024 * 1024;
30
+ const DEFAULT_PROBE_LIMIT = 100;
31
+ const DISCOVERY_CONCURRENCY = 16;
32
+ const DROP_EVENT_TYPES = new Set([
33
+ "assistant_delta",
34
+ "thinking_delta",
35
+ "permission_mode",
36
+ "rate_limit",
37
+ "unknown",
38
+ ]);
39
+
40
+ function claudeHome(options?: { env?: Record<string, string> }): string {
41
+ return resolveClaudeHome(options?.env?.["CLAUDE_CONFIG_DIR"]);
42
+ }
43
+
44
+ async function listClaudeSessionFiles(
45
+ home: string,
46
+ options: { limit?: number; includeSubagents?: boolean } = {},
47
+ ): Promise<string[]> {
48
+ const projectsRoot = path.join(home, "projects");
49
+ let projects;
50
+ try {
51
+ projects = await readdir(projectsRoot, { withFileTypes: true });
52
+ } catch {
53
+ return [];
54
+ }
55
+ projects.sort((a, b) => a.name.localeCompare(b.name));
56
+ const files: string[] = [];
57
+ const cap = Math.max(1, options.limit ?? Number.MAX_SAFE_INTEGER);
58
+ for (const project of projects) {
59
+ if (files.length >= cap) break;
60
+ if (!project.isDirectory()) continue;
61
+ let entries;
62
+ try {
63
+ entries = await readdir(path.join(projectsRoot, project.name), { withFileTypes: true });
64
+ } catch {
65
+ continue;
66
+ }
67
+ entries.sort((a, b) => a.name.localeCompare(b.name));
68
+ for (const entry of entries) {
69
+ if (files.length >= cap) break;
70
+ // Top-level UUID files are main sessions. Nested subagent directories and
71
+ // symlinks are deliberately ignored.
72
+ if (entry.isFile() && UUID_JSONL_RE.test(entry.name)) {
73
+ files.push(path.join(projectsRoot, project.name, entry.name));
74
+ }
75
+ if (options.includeSubagents && entry.isDirectory() && UUID_JSONL_RE.test(`${entry.name}.jsonl`)) {
76
+ const subagentsDir = path.join(projectsRoot, project.name, entry.name, "subagents");
77
+ let subagents;
78
+ try {
79
+ subagents = await readdir(subagentsDir, { withFileTypes: true });
80
+ } catch {
81
+ continue;
82
+ }
83
+ subagents.sort((a, b) => a.name.localeCompare(b.name));
84
+ for (const subagent of subagents) {
85
+ if (files.length >= cap) break;
86
+ if (subagent.isFile() && /^agent-[a-z0-9]+\.jsonl$/i.test(subagent.name)) {
87
+ files.push(path.join(subagentsDir, subagent.name));
88
+ }
89
+ }
90
+ }
91
+ }
92
+ }
93
+ return files;
94
+ }
95
+
96
+ function subagentParentId(transcriptPath: string): string | null {
97
+ if (path.basename(path.dirname(transcriptPath)) !== "subagents") return null;
98
+ return path.basename(path.dirname(path.dirname(transcriptPath))) || null;
99
+ }
100
+
101
+ async function parentSessionMetadata(
102
+ transcriptPath: string,
103
+ ): Promise<{ cwd: string | null; branch: string | null }> {
104
+ const parentId = subagentParentId(transcriptPath);
105
+ if (!parentId) return { cwd: null, branch: null };
106
+ const projectDir = path.dirname(path.dirname(path.dirname(transcriptPath)));
107
+ const parentTranscript = path.join(projectDir, `${parentId}.jsonl`);
108
+ for await (const line of readJsonlRecords(parentTranscript)) {
109
+ const cwd = absoluteCwd(asString(line.raw["cwd"]));
110
+ const branch = asString(line.raw["gitBranch"]);
111
+ if (cwd) return { cwd, branch };
112
+ if (line.nextOffset >= TITLE_SCAN_BYTES) break;
113
+ }
114
+ return { cwd: null, branch: null };
115
+ }
116
+
117
+ function claudeUserText(record: Record<string, unknown>): string | null {
118
+ if (record["type"] !== "user") return null;
119
+ const message = asRecord(record["message"]);
120
+ if (!message || message["role"] !== "user") return null;
121
+ return meaningfulHumanText(textFromContent(
122
+ message["content"],
123
+ new Set(["text", "input_text"]),
124
+ ));
125
+ }
126
+
127
+ function absoluteCwd(value: string | null): string | null {
128
+ return value && path.isAbsolute(value) ? value : null;
129
+ }
130
+
131
+ async function inspectClaudeSession(
132
+ transcriptPath: string,
133
+ options: LocalHistoryDiscoverOptions,
134
+ ): Promise<LocalHistorySession | null> {
135
+ const fileIdentity = path.basename(transcriptPath, ".jsonl");
136
+ const parentId = subagentParentId(transcriptPath);
137
+ const externalSessionId = parentId
138
+ ? `subagent:${parentId}:${fileIdentity}`
139
+ : fileIdentity;
140
+ let cwd: string | null = null;
141
+ let title: string | null = null;
142
+ let firstPrompt: string | null = null;
143
+ let startedAt: string | null = null;
144
+ let branch: string | null = null;
145
+ let sidechain = false;
146
+
147
+ for await (const line of readJsonlRecords(transcriptPath)) {
148
+ const record = line.raw;
149
+ if (record["isSidechain"] === true) sidechain = true;
150
+ cwd ??= absoluteCwd(asString(record["cwd"]));
151
+ startedAt ??= isoDate(record["timestamp"]);
152
+ branch ??= asString(record["gitBranch"]);
153
+ if (record["type"] === "ai-title") title ??= cleanTitle(asString(record["aiTitle"]));
154
+ firstPrompt ??= claudeUserText(record);
155
+ if (cwd && firstPrompt && (title || line.nextOffset >= TITLE_SCAN_BYTES)) break;
156
+ if (line.nextOffset >= MAX_DISCOVERY_SCAN_BYTES) break;
157
+ }
158
+
159
+ if (!cwd && parentId) {
160
+ const parent = await parentSessionMetadata(transcriptPath);
161
+ cwd = parent.cwd;
162
+ branch ??= parent.branch;
163
+ }
164
+
165
+ if ((options.mainSessionsOnly ?? true) && sidechain) return null;
166
+ const hasUserMessage = firstPrompt !== null;
167
+ if ((options.requireUserMessage ?? true) && !hasUserMessage) return null;
168
+ if ((options.mainSessionsOnly ?? true) && !cwd) return null;
169
+ if (options.cwd && (!cwd || path.normalize(cwd) !== path.normalize(options.cwd))) return null;
170
+
171
+ const source = await sourceFingerprint(transcriptPath);
172
+ return {
173
+ version: 1,
174
+ providerType: "claude",
175
+ externalSessionId,
176
+ transcriptPath,
177
+ cwd,
178
+ title: title ?? cleanTitle(firstPrompt),
179
+ startedAt,
180
+ updatedAt: new Date(Number(BigInt(source.modifiedAtNs) / 1_000_000n)).toISOString(),
181
+ branch,
182
+ gitOriginUrl: null,
183
+ archiveState: "unknown",
184
+ hasUserMessage,
185
+ source,
186
+ };
187
+ }
188
+
189
+ function baseFields(
190
+ session: LocalHistorySession,
191
+ raw: Record<string, unknown>,
192
+ eventId: string,
193
+ ): BaseStreamEventFields & { eventId: string } {
194
+ const message = asRecord(raw["message"]);
195
+ return {
196
+ timestamp: isoDate(raw["timestamp"], session.startedAt ?? new Date(0).toISOString())!,
197
+ providerType: "claude",
198
+ sessionId: session.externalSessionId,
199
+ messageId: asString(message?.["id"]),
200
+ eventId,
201
+ turnId: null,
202
+ parentToolCallId: asString(raw["parent_tool_use_id"]),
203
+ raw,
204
+ };
205
+ }
206
+
207
+ function normalizedClaudeEvents(
208
+ session: LocalHistorySession,
209
+ raw: Record<string, unknown>,
210
+ text: string,
211
+ eventId: string,
212
+ ): Array<StreamEvent & { eventId: string }> {
213
+ return parseStreamLine(text)
214
+ .filter((event) => !DROP_EVENT_TYPES.has(event.type))
215
+ .map((event) => ({
216
+ ...event,
217
+ timestamp: isoDate(raw["timestamp"], event.timestamp)!,
218
+ providerType: "claude",
219
+ sessionId: session.externalSessionId,
220
+ eventId,
221
+ raw,
222
+ } as StreamEvent & { eventId: string }));
223
+ }
224
+
225
+ export const claudeLocalHistory: LocalHistoryOps = {
226
+ async probe(options?: LocalHistoryProbeOptions) {
227
+ const home = claudeHome(options);
228
+ const homeAvailable = await isDirectory(home);
229
+ const files = homeAvailable
230
+ ? await listClaudeSessionFiles(home, { limit: options?.limit ?? DEFAULT_PROBE_LIMIT })
231
+ : [];
232
+ return {
233
+ providerType: "claude",
234
+ homeAvailable,
235
+ historyAvailable: files.length > 0,
236
+ approximateCount: files.length,
237
+ };
238
+ },
239
+
240
+ async *discover(options: LocalHistoryDiscoverOptions = {}) {
241
+ const files = await listClaudeSessionFiles(claudeHome(options), {
242
+ includeSubagents: options.mainSessionsOnly === false,
243
+ });
244
+ const sessions = await recentEligible(
245
+ files,
246
+ { limit: options.limit, concurrency: DISCOVERY_CONCURRENCY },
247
+ async (filePath) => {
248
+ try {
249
+ return await inspectClaudeSession(filePath, options);
250
+ } catch {
251
+ return null;
252
+ }
253
+ },
254
+ );
255
+ for (const session of sessions) yield session;
256
+ },
257
+
258
+ async *read(session, options = {}): AsyncIterable<LocalHistoryYield> {
259
+ if (session.providerType !== "claude" || !path.isAbsolute(session.transcriptPath)) {
260
+ throw new Error("Invalid Claude local history session");
261
+ }
262
+ for await (const line of readJsonlRecords(
263
+ session.transcriptPath,
264
+ options.fromOffset ?? 0,
265
+ { rejectChanges: true },
266
+ )) {
267
+ const eventId = asString(line.raw["uuid"])
268
+ ?? `claude:${session.externalSessionId}:${line.lineStartOffset}`;
269
+ const events: LocalHistoryYield["event"][] = [];
270
+ const userText = claudeUserText(line.raw);
271
+ if (userText) {
272
+ events.push({ type: "user", text: userText, ...baseFields(session, line.raw, eventId) });
273
+ }
274
+ events.push(...normalizedClaudeEvents(session, line.raw, line.text, eventId));
275
+ for (let partIndex = 0; partIndex < events.length; partIndex++) {
276
+ yield {
277
+ event: events[partIndex]!,
278
+ lineStartOffset: line.lineStartOffset,
279
+ nextOffset: line.nextOffset,
280
+ partIndex,
281
+ };
282
+ }
283
+ }
284
+ },
285
+
286
+ fingerprint(session, options) {
287
+ if (session.providerType !== "claude") throw new Error("Invalid Claude local history session");
288
+ return sourceFingerprint(session.transcriptPath, options?.sha256 ?? false);
289
+ },
290
+ };
@@ -35,6 +35,8 @@ export const claudeProvider: ProviderModule = {
35
35
  modes: false,
36
36
  goals: claudeGoalCapability,
37
37
  durableSessions: true,
38
+ durableHistory: true,
39
+ localHistory: true,
38
40
  },
39
41
  // Heavy machinery (execute.ts, session.ts, attach.ts) loads lazily on first
40
42
  // use — every ProviderModule method is already async, so this is invisible to
@@ -48,6 +50,30 @@ export const claudeProvider: ProviderModule = {
48
50
  transcript: claudeTranscriptOps,
49
51
  attachSession: async (record, opts) =>
50
52
  (await import("./attach.js")).attachClaudeSession(record, opts),
53
+ attachHistory: async (record, opts) => {
54
+ const [{ attachClaudeSession }, { historyFromSessionAttachment }] = await Promise.all([
55
+ import("./attach.js"),
56
+ import("../../sessions/history.js"),
57
+ ]);
58
+ return historyFromSessionAttachment("claude", await attachClaudeSession(record, opts));
59
+ },
60
+ localHistory: {
61
+ probe: (options) => import("./history.js").then((module) => module.claudeLocalHistory.probe(options)),
62
+ discover: (options) => ({
63
+ async *[Symbol.asyncIterator]() {
64
+ const module = await import("./history.js");
65
+ yield* module.claudeLocalHistory.discover(options);
66
+ },
67
+ }),
68
+ read: (session, options) => ({
69
+ async *[Symbol.asyncIterator]() {
70
+ const module = await import("./history.js");
71
+ yield* module.claudeLocalHistory.read(session, options);
72
+ },
73
+ }),
74
+ fingerprint: (session, options) => import("./history.js")
75
+ .then((module) => module.claudeLocalHistory.fingerprint(session, options)),
76
+ },
51
77
  };
52
78
 
53
79
  export {
@@ -216,11 +216,7 @@ export async function createClaudeSession(ctx: SessionContext): Promise<AgentSes
216
216
  throw new Error("Failed to open stdio on Claude process");
217
217
  }
218
218
 
219
- const session = new ClaudeSessionImpl(proc, ctx, skillsDir, mcpConfigPath);
220
-
221
- // On resume, restore an unmet native goal from the transcript (best-effort,
222
- // non-blocking so session creation isn't delayed by a transcript read).
223
- if (resumeId) void session.hydrateGoalFromTranscript(resumeId);
219
+ const session = new ClaudeSessionImpl(proc, { ...ctx, cwd }, skillsDir, mcpConfigPath);
224
220
 
225
221
  // Wire up AbortSignal to close the session
226
222
  if (ctx.signal) {
@@ -231,6 +227,10 @@ export async function createClaudeSession(ctx: SessionContext): Promise<AgentSes
231
227
  }
232
228
  }
233
229
 
230
+ // Finish historical hydration before exposing the session. This prevents a
231
+ // late transcript read from overwriting a goal the caller sets immediately.
232
+ if (resumeId && session.state !== "closed") await session.hydrateGoalFromTranscript(resumeId);
233
+
234
234
  return session;
235
235
  }
236
236
 
@@ -307,6 +307,7 @@ export class ClaudeSessionImpl implements AgentSession {
307
307
  /** Tail state for observing native goal_status (transcript-only, not on stdout). */
308
308
  private _goalScanOffset = 0;
309
309
  private _goalPoll: ReturnType<typeof setInterval> | null = null;
310
+ private readonly cwd: string;
310
311
 
311
312
  constructor(
312
313
  private readonly proc: ChildProcess,
@@ -314,6 +315,7 @@ export class ClaudeSessionImpl implements AgentSession {
314
315
  private readonly skillsDir: string | null,
315
316
  private readonly mcpConfigPath: string | null = null,
316
317
  ) {
318
+ this.cwd = ctx.cwd ?? process.cwd();
317
319
  this._goals = new GoalController({
318
320
  providerType: "claude",
319
321
  capability: claudeGoalCapability,
@@ -384,6 +386,7 @@ export class ClaudeSessionImpl implements AgentSession {
384
386
 
385
387
  get sessionId(): string | null { return this._sessionId; }
386
388
  get state(): SessionState { return this._state; }
389
+ private isClosed(): boolean { return this._state === "closed"; }
387
390
 
388
391
  /**
389
392
  * Durable identity for persistence + later `attachSession`. Null until Claude
@@ -392,7 +395,7 @@ export class ClaudeSessionImpl implements AgentSession {
392
395
  */
393
396
  describe(): SessionRecord | null {
394
397
  if (!this._sessionId) return null;
395
- const cwd = this.ctx.cwd ?? null;
398
+ const cwd = this.cwd;
396
399
  const params = claudeSessionCodec.serialize({
397
400
  sessionId: this._sessionId,
398
401
  ...(cwd ? { cwd } : {}),
@@ -1144,7 +1147,7 @@ export class ClaudeSessionImpl implements AgentSession {
1144
1147
  try {
1145
1148
  const found = await claudeTranscriptOps.find({
1146
1149
  sessionId,
1147
- cwd: this.ctx.cwd ?? process.cwd(),
1150
+ cwd: this.cwd,
1148
1151
  });
1149
1152
  if (found) this._transcriptPath = found.filePath;
1150
1153
  } catch { /* best effort */ } finally {
@@ -1166,20 +1169,22 @@ export class ClaudeSessionImpl implements AgentSession {
1166
1169
  */
1167
1170
  async hydrateGoalFromTranscript(sessionId: string): Promise<void> {
1168
1171
  try {
1172
+ if (this.isClosed()) return;
1169
1173
  const found = await claudeTranscriptOps.find({
1170
1174
  sessionId,
1171
- cwd: this.ctx.cwd ?? process.cwd(),
1175
+ cwd: this.cwd,
1172
1176
  });
1173
- if (!found) return;
1177
+ if (!found || this.isClosed()) return;
1174
1178
  this._transcriptPath = found.filePath;
1175
1179
  const events: StreamEvent[] = [];
1176
1180
  let endOffset = 0;
1177
1181
  for await (const { event, offset } of claudeTranscriptOps.read({ filePath: found.filePath })) {
1182
+ if (this.isClosed()) return;
1178
1183
  endOffset = offset;
1179
1184
  if (event.type === "goal_status") events.push(event);
1180
1185
  }
1181
1186
  const last = latestGoalFromEvents(events);
1182
- if (last && !isTerminalGoalStatus(last.status)) {
1187
+ if (!this.isClosed() && last && !isTerminalGoalStatus(last.status)) {
1183
1188
  // Start observing from the END of the historical transcript so the poller
1184
1189
  // surfaces only post-resume transitions (we already hydrated the state).
1185
1190
  this._goalScanOffset = endOffset;