@agentex/agent 0.0.26 → 0.0.28

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 (172) hide show
  1. package/CHANGELOG.md +118 -0
  2. package/README.md +58 -7
  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/index.d.ts +2 -2
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js.map +1 -1
  16. package/dist/providers/claude/attach.d.ts.map +1 -1
  17. package/dist/providers/claude/attach.js +50 -9
  18. package/dist/providers/claude/attach.js.map +1 -1
  19. package/dist/providers/claude/index.d.ts.map +1 -1
  20. package/dist/providers/claude/index.js +8 -0
  21. package/dist/providers/claude/index.js.map +1 -1
  22. package/dist/providers/claude/session.d.ts +2 -0
  23. package/dist/providers/claude/session.d.ts.map +1 -1
  24. package/dist/providers/claude/session.js +17 -10
  25. package/dist/providers/claude/session.js.map +1 -1
  26. package/dist/providers/codex/attach.d.ts.map +1 -1
  27. package/dist/providers/codex/attach.js +40 -3
  28. package/dist/providers/codex/attach.js.map +1 -1
  29. package/dist/providers/codex/index.d.ts.map +1 -1
  30. package/dist/providers/codex/index.js +8 -0
  31. package/dist/providers/codex/index.js.map +1 -1
  32. package/dist/providers/codex/session.d.ts.map +1 -1
  33. package/dist/providers/codex/session.js +6 -0
  34. package/dist/providers/codex/session.js.map +1 -1
  35. package/dist/providers/codex/transcript.d.ts +3 -1
  36. package/dist/providers/codex/transcript.d.ts.map +1 -1
  37. package/dist/providers/codex/transcript.js +3 -1
  38. package/dist/providers/codex/transcript.js.map +1 -1
  39. package/dist/providers/cursor/discovery.d.ts +6 -0
  40. package/dist/providers/cursor/discovery.d.ts.map +1 -0
  41. package/dist/providers/cursor/discovery.js +118 -0
  42. package/dist/providers/cursor/discovery.js.map +1 -0
  43. package/dist/providers/cursor/execute.d.ts.map +1 -1
  44. package/dist/providers/cursor/execute.js +83 -45
  45. package/dist/providers/cursor/execute.js.map +1 -1
  46. package/dist/providers/cursor/index.d.ts.map +1 -1
  47. package/dist/providers/cursor/index.js +17 -4
  48. package/dist/providers/cursor/index.js.map +1 -1
  49. package/dist/providers/cursor/parse.js +1 -1
  50. package/dist/providers/cursor/parse.js.map +1 -1
  51. package/dist/providers/cursor/probe.d.ts +3 -0
  52. package/dist/providers/cursor/probe.d.ts.map +1 -0
  53. package/dist/providers/cursor/probe.js +89 -0
  54. package/dist/providers/cursor/probe.js.map +1 -0
  55. package/dist/providers/cursor/runtime.d.ts +4 -0
  56. package/dist/providers/cursor/runtime.d.ts.map +1 -0
  57. package/dist/providers/cursor/runtime.js +12 -0
  58. package/dist/providers/cursor/runtime.js.map +1 -0
  59. package/dist/providers/cursor/session.d.ts +3 -0
  60. package/dist/providers/cursor/session.d.ts.map +1 -0
  61. package/dist/providers/cursor/session.js +14 -0
  62. package/dist/providers/cursor/session.js.map +1 -0
  63. package/dist/providers/opencode/client.d.ts +14 -0
  64. package/dist/providers/opencode/client.d.ts.map +1 -0
  65. package/dist/providers/opencode/client.js +40 -0
  66. package/dist/providers/opencode/client.js.map +1 -0
  67. package/dist/providers/opencode/discovery.d.ts +4 -0
  68. package/dist/providers/opencode/discovery.d.ts.map +1 -0
  69. package/dist/providers/opencode/discovery.js +68 -0
  70. package/dist/providers/opencode/discovery.js.map +1 -0
  71. package/dist/providers/opencode/execute.d.ts.map +1 -1
  72. package/dist/providers/opencode/execute.js +219 -164
  73. package/dist/providers/opencode/execute.js.map +1 -1
  74. package/dist/providers/opencode/history.d.ts +24 -0
  75. package/dist/providers/opencode/history.d.ts.map +1 -0
  76. package/dist/providers/opencode/history.js +242 -0
  77. package/dist/providers/opencode/history.js.map +1 -0
  78. package/dist/providers/opencode/http-session.d.ts.map +1 -1
  79. package/dist/providers/opencode/http-session.js +333 -49
  80. package/dist/providers/opencode/http-session.js.map +1 -1
  81. package/dist/providers/opencode/index.d.ts.map +1 -1
  82. package/dist/providers/opencode/index.js +26 -3
  83. package/dist/providers/opencode/index.js.map +1 -1
  84. package/dist/providers/opencode/manager.d.ts +11 -0
  85. package/dist/providers/opencode/manager.d.ts.map +1 -0
  86. package/dist/providers/opencode/manager.js +240 -0
  87. package/dist/providers/opencode/manager.js.map +1 -0
  88. package/dist/providers/opencode/modes.d.ts +3 -0
  89. package/dist/providers/opencode/modes.d.ts.map +1 -0
  90. package/dist/providers/opencode/modes.js +30 -0
  91. package/dist/providers/opencode/modes.js.map +1 -0
  92. package/dist/providers/opencode/parse.d.ts.map +1 -1
  93. package/dist/providers/opencode/parse.js +2 -22
  94. package/dist/providers/opencode/parse.js.map +1 -1
  95. package/dist/providers/opencode/probe.d.ts +3 -0
  96. package/dist/providers/opencode/probe.d.ts.map +1 -0
  97. package/dist/providers/opencode/probe.js +107 -0
  98. package/dist/providers/opencode/probe.js.map +1 -0
  99. package/dist/providers/opencode/runtime.d.ts +8 -0
  100. package/dist/providers/opencode/runtime.d.ts.map +1 -0
  101. package/dist/providers/opencode/runtime.js +12 -0
  102. package/dist/providers/opencode/runtime.js.map +1 -0
  103. package/dist/providers/opencode/server.d.ts +18 -3
  104. package/dist/providers/opencode/server.d.ts.map +1 -1
  105. package/dist/providers/opencode/server.js +0 -0
  106. package/dist/providers/opencode/server.js.map +1 -1
  107. package/dist/providers/opencode/skill-config.d.ts +11 -0
  108. package/dist/providers/opencode/skill-config.d.ts.map +1 -0
  109. package/dist/providers/opencode/skill-config.js +57 -0
  110. package/dist/providers/opencode/skill-config.js.map +1 -0
  111. package/dist/sessions/exec-backed.d.ts +10 -0
  112. package/dist/sessions/exec-backed.d.ts.map +1 -0
  113. package/dist/sessions/exec-backed.js +141 -0
  114. package/dist/sessions/exec-backed.js.map +1 -0
  115. package/dist/sessions/history.d.ts +4 -0
  116. package/dist/sessions/history.d.ts.map +1 -0
  117. package/dist/sessions/history.js +59 -0
  118. package/dist/sessions/history.js.map +1 -0
  119. package/dist/sessions/index.d.ts +3 -0
  120. package/dist/sessions/index.d.ts.map +1 -1
  121. package/dist/sessions/index.js +2 -0
  122. package/dist/sessions/index.js.map +1 -1
  123. package/dist/types.d.ts +149 -5
  124. package/dist/types.d.ts.map +1 -1
  125. package/dist/types.js.map +1 -1
  126. package/dist/utils/auth.d.ts.map +1 -1
  127. package/dist/utils/auth.js +36 -6
  128. package/dist/utils/auth.js.map +1 -1
  129. package/dist/utils/binary.d.ts.map +1 -1
  130. package/dist/utils/binary.js +3 -0
  131. package/dist/utils/binary.js.map +1 -1
  132. package/dist/utils/endpoint.js +3 -3
  133. package/dist/utils/endpoint.js.map +1 -1
  134. package/package.json +4 -2
  135. package/src/derived.ts +126 -3
  136. package/src/goals/controller.ts +17 -0
  137. package/src/goals/normalize.ts +5 -0
  138. package/src/index.ts +13 -0
  139. package/src/providers/claude/attach.ts +48 -6
  140. package/src/providers/claude/index.ts +8 -0
  141. package/src/providers/claude/session.ts +15 -10
  142. package/src/providers/codex/attach.ts +48 -3
  143. package/src/providers/codex/index.ts +8 -0
  144. package/src/providers/codex/session.ts +5 -0
  145. package/src/providers/codex/transcript.ts +5 -1
  146. package/src/providers/cursor/discovery.ts +114 -0
  147. package/src/providers/cursor/execute.ts +67 -30
  148. package/src/providers/cursor/index.ts +19 -5
  149. package/src/providers/cursor/parse.ts +1 -1
  150. package/src/providers/cursor/probe.ts +90 -0
  151. package/src/providers/cursor/runtime.ts +11 -0
  152. package/src/providers/cursor/session.ts +15 -0
  153. package/src/providers/opencode/client.ts +44 -0
  154. package/src/providers/opencode/discovery.ts +69 -0
  155. package/src/providers/opencode/execute.ts +64 -12
  156. package/src/providers/opencode/history.ts +270 -0
  157. package/src/providers/opencode/http-session.ts +319 -50
  158. package/src/providers/opencode/index.ts +26 -3
  159. package/src/providers/opencode/manager.ts +269 -0
  160. package/src/providers/opencode/modes.ts +31 -0
  161. package/src/providers/opencode/parse.ts +2 -22
  162. package/src/providers/opencode/probe.ts +113 -0
  163. package/src/providers/opencode/runtime.ts +13 -0
  164. package/src/providers/opencode/server.ts +0 -0
  165. package/src/providers/opencode/skill-config.ts +64 -0
  166. package/src/sessions/exec-backed.ts +169 -0
  167. package/src/sessions/history.ts +76 -0
  168. package/src/sessions/index.ts +3 -0
  169. package/src/types.ts +160 -3
  170. package/src/utils/auth.ts +35 -6
  171. package/src/utils/binary.ts +3 -0
  172. package/src/utils/endpoint.ts +3 -3
package/src/derived.ts CHANGED
@@ -1,11 +1,18 @@
1
1
  import type {
2
+ AgentSession,
3
+ AttachOptions,
4
+ HistoryAttachment,
2
5
  ProviderCapabilities,
3
6
  ProviderConfig,
4
7
  ProviderModel,
5
8
  ProviderModule,
9
+ ProviderRuntimeContext,
10
+ SessionAttachment,
11
+ SessionRecord,
6
12
  } from "./types.js";
7
13
  import { getProvider, registerProvider } from "./registry.js";
8
14
  import { acpProvider } from "./providers/acp/index.js";
15
+ import { MalformedSessionRecordError } from "./sessions/record.js";
9
16
 
10
17
  /**
11
18
  * Declarative configuration for a *derived* provider — a new provider id that
@@ -76,6 +83,38 @@ export function getAcpFactory(): AcpFactory | null {
76
83
  return acpFactory;
77
84
  }
78
85
 
86
+ function withProviderType(record: SessionRecord | null, providerType: string): SessionRecord | null {
87
+ return record ? { ...record, providerType } : null;
88
+ }
89
+
90
+ function wrapDerivedSession(session: AgentSession, providerType: string): AgentSession {
91
+ return new Proxy(session, {
92
+ get(target, property) {
93
+ if (property === "describe") {
94
+ return target.describe
95
+ ? () => withProviderType(target.describe!(), providerType)
96
+ : undefined;
97
+ }
98
+ if (property === "describeHistory") {
99
+ return target.describeHistory
100
+ ? () => withProviderType(target.describeHistory!(), providerType)
101
+ : undefined;
102
+ }
103
+ const value = Reflect.get(target, property, target) as unknown;
104
+ return typeof value === "function" ? value.bind(target) : value;
105
+ },
106
+ });
107
+ }
108
+
109
+ function assertDerivedRecord(record: SessionRecord, providerType: string): void {
110
+ if (record.providerType !== providerType) {
111
+ throw new MalformedSessionRecordError(
112
+ `${providerType} attach requires providerType ${JSON.stringify(providerType)}; got ${JSON.stringify(record.providerType)}`,
113
+ "providerType",
114
+ );
115
+ }
116
+ }
117
+
79
118
  /**
80
119
  * Build a derived `ProviderModule` from a base provider plus an overlay. The
81
120
  * returned module is NOT auto-registered — pass it to `registerProvider`, or
@@ -124,15 +163,21 @@ export function defineDerivedProvider(cfg: DerivedProviderConfig): ProviderModul
124
163
 
125
164
  if (base.createSession) {
126
165
  const baseCreate = base.createSession.bind(base);
127
- derived.createSession = (ctx) =>
128
- baseCreate({ ...ctx, env: overlayEnv(ctx.env), config: overlayConfig(ctx.config) });
166
+ derived.createSession = async (ctx) => wrapDerivedSession(
167
+ await baseCreate({ ...ctx, env: overlayEnv(ctx.env), config: overlayConfig(ctx.config) }),
168
+ cfg.id,
169
+ );
129
170
  }
130
171
  if (cfg.models) {
131
172
  const models = cfg.models;
132
173
  derived.listModels = async () => models;
133
174
  } else if (base.listModels) {
134
175
  const baseList = base.listModels.bind(base);
135
- derived.listModels = (opts) => baseList(opts);
176
+ derived.listModels = (opts) => baseList({
177
+ ...opts,
178
+ env: overlayEnv(opts?.env),
179
+ config: overlayConfig(opts?.config),
180
+ });
136
181
  }
137
182
  if (base.listModes) {
138
183
  const baseModes = base.listModes.bind(base);
@@ -144,10 +189,88 @@ export function defineDerivedProvider(cfg: DerivedProviderConfig): ProviderModul
144
189
  derived.checkQuota = (qctx) =>
145
190
  baseQuota({ ...qctx, env: overlayEnv(qctx?.env), config: overlayConfig(qctx?.config) });
146
191
  }
192
+ if (base.probeCapabilities) {
193
+ const baseProbe = base.probeCapabilities.bind(base);
194
+ derived.probeCapabilities = (ctx) => baseProbe({
195
+ ...ctx,
196
+ env: overlayEnv(ctx?.env),
197
+ config: overlayConfig(ctx?.config),
198
+ });
199
+ }
200
+ if (base.upstreamProviders) {
201
+ const manager = base.upstreamProviders;
202
+ const runtime = (ctx?: ProviderRuntimeContext): ProviderRuntimeContext => ({
203
+ ...ctx,
204
+ env: overlayEnv(ctx?.env),
205
+ config: overlayConfig(ctx?.config),
206
+ });
207
+ derived.upstreamProviders = {
208
+ list: (ctx) => manager.list(runtime(ctx)),
209
+ authMethods: (providerId, ctx) => manager.authMethods(providerId, runtime(ctx)),
210
+ setApiKey: (providerId, key, ctx) => manager.setApiKey(providerId, key, runtime(ctx)),
211
+ beginOAuth: (providerId, methodId, inputs, ctx) =>
212
+ manager.beginOAuth(providerId, methodId, inputs, runtime(ctx)),
213
+ completeOAuth: (flowId, code, ctx) => manager.completeOAuth(flowId, code, runtime(ctx)),
214
+ canDisconnect: (providerId, ctx) => manager.canDisconnect(providerId, runtime(ctx)),
215
+ disconnect: (providerId, ctx) => manager.disconnect(providerId, runtime(ctx)),
216
+ };
217
+ }
218
+ if (base.attachSession && derived.createSession) {
219
+ const baseAttach = base.attachSession.bind(base);
220
+ derived.attachSession = async (record, opts): Promise<SessionAttachment> => {
221
+ assertDerivedRecord(record, cfg.id);
222
+ const attachment = await baseAttach(
223
+ { ...record, providerType: base.type },
224
+ overlayAttachOptions(opts, overlayEnv, overlayConfig),
225
+ );
226
+ const derivedRecord = withProviderType(attachment.record, cfg.id)!;
227
+ return {
228
+ ...attachment,
229
+ record: derivedRecord,
230
+ resume: (ctx) => derived.createSession!({
231
+ ...ctx,
232
+ cwd: ctx?.cwd ?? derivedRecord.cwd ?? undefined,
233
+ sessionParams: derivedRecord.params,
234
+ }),
235
+ };
236
+ };
237
+ }
238
+ if (base.attachHistory && derived.createSession) {
239
+ const baseAttachHistory = base.attachHistory.bind(base);
240
+ derived.attachHistory = async (record, opts): Promise<HistoryAttachment> => {
241
+ assertDerivedRecord(record, cfg.id);
242
+ const attachment = await baseAttachHistory(
243
+ { ...record, providerType: base.type },
244
+ overlayAttachOptions(opts, overlayEnv, overlayConfig),
245
+ );
246
+ const derivedRecord = withProviderType(attachment.record, cfg.id)!;
247
+ return {
248
+ ...attachment,
249
+ record: derivedRecord,
250
+ resume: (ctx) => derived.createSession!({
251
+ ...ctx,
252
+ cwd: ctx?.cwd ?? derivedRecord.cwd ?? undefined,
253
+ sessionParams: derivedRecord.params,
254
+ }),
255
+ };
256
+ };
257
+ }
147
258
 
148
259
  return derived;
149
260
  }
150
261
 
262
+ function overlayAttachOptions(
263
+ opts: AttachOptions | undefined,
264
+ overlayEnv: (env?: Record<string, string>) => Record<string, string>,
265
+ overlayConfig: (config?: ProviderConfig) => ProviderConfig,
266
+ ): AttachOptions {
267
+ return {
268
+ ...opts,
269
+ env: overlayEnv(opts?.env),
270
+ config: overlayConfig(opts?.config),
271
+ };
272
+ }
273
+
151
274
  // ---------------------------------------------------------------------------
152
275
  // Config-file loader
153
276
  // ---------------------------------------------------------------------------
@@ -139,6 +139,9 @@ export class GoalController {
139
139
  * sentinel/loop), call `setGoal` again; hydrate does not restart the loop.
140
140
  */
141
141
  hydrate(state: GoalState): void {
142
+ // Hydration is historical initialization, never an overwrite. A live
143
+ // parser event or caller-set goal that won the race remains authoritative.
144
+ if (this.state !== null) return;
142
145
  this.state = state;
143
146
  this.mode = "idle";
144
147
  }
@@ -387,6 +390,19 @@ export class GoalController {
387
390
  // re-enters onTurnSettled and continues the loop.
388
391
  const nudge = verdict.nudge ?? defaultNudge(objective, this.iterations);
389
392
  void this.deps.send(nudge).catch(() => {});
393
+ } catch (error) {
394
+ if (gen !== this.generation) return;
395
+ this.transition({
396
+ objective,
397
+ status: "blocked",
398
+ met: false,
399
+ enforced: true,
400
+ source: "agentex",
401
+ blockedReason: "sentinel_error",
402
+ errorMessage: error instanceof Error ? error.message : String(error),
403
+ });
404
+ this.mode = "idle";
405
+ this.sentinel = null;
390
406
  } finally {
391
407
  this.evaluating = false;
392
408
  }
@@ -433,6 +449,7 @@ export class GoalController {
433
449
  raw: { synthetic: "goal", ...fields },
434
450
  };
435
451
  if (fields.blockedReason !== undefined) event.blockedReason = fields.blockedReason;
452
+ if (fields.errorMessage !== undefined) event.errorMessage = fields.errorMessage;
436
453
  if (fields.tokensUsed !== undefined) event.tokensUsed = fields.tokensUsed;
437
454
  if (fields.timeUsedSeconds !== undefined) event.timeUsedSeconds = fields.timeUsedSeconds;
438
455
  if (fields.tokenBudget !== undefined) event.tokenBudget = fields.tokenBudget;
@@ -33,6 +33,7 @@ export interface NormalizedGoalFields {
33
33
  enforced: boolean;
34
34
  source: GoalSource;
35
35
  blockedReason?: GoalBlockedReason;
36
+ errorMessage?: string;
36
37
  tokensUsed?: number;
37
38
  timeUsedSeconds?: number;
38
39
  tokenBudget?: number;
@@ -90,6 +91,9 @@ export function normalizeCodexGoalStatus(raw: string | null | undefined): {
90
91
  case "budget-limited":
91
92
  case "budget_limited":
92
93
  case "budgetlimited": // camelCase `budgetLimited`, lowercased
94
+ case "usage-limited":
95
+ case "usage_limited":
96
+ case "usagelimited": // camelCase `usageLimited`, lowercased
93
97
  return { status: "blocked", met: false, blockedReason: "budget" };
94
98
  case "blocked":
95
99
  return { status: "blocked", met: false, blockedReason: "needs_input" };
@@ -165,6 +169,7 @@ export function goalStateFromEvent(event: GoalStatusEvent): GoalState {
165
169
  updatedAt: event.timestamp,
166
170
  };
167
171
  if (event.blockedReason !== undefined) state.blockedReason = event.blockedReason;
172
+ if (event.errorMessage !== undefined) state.errorMessage = event.errorMessage;
168
173
  if (event.tokensUsed !== undefined) state.tokensUsed = event.tokensUsed;
169
174
  if (event.timeUsedSeconds !== undefined) state.timeUsedSeconds = event.timeUsedSeconds;
170
175
  if (event.tokenBudget !== undefined) state.tokenBudget = event.tokenBudget;
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,
@@ -235,6 +243,11 @@ export type {
235
243
  CatchUpOptions,
236
244
  AttachOptions,
237
245
  SessionAttachment,
246
+ HistoryCheckpoint,
247
+ HistorySource,
248
+ HistoryCatchUpYield,
249
+ HistoryCatchUpOptions,
250
+ HistoryAttachment,
238
251
  } from "./types.js";
239
252
 
240
253
  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
  }
@@ -35,6 +35,7 @@ export const claudeProvider: ProviderModule = {
35
35
  modes: false,
36
36
  goals: claudeGoalCapability,
37
37
  durableSessions: true,
38
+ durableHistory: true,
38
39
  },
39
40
  // Heavy machinery (execute.ts, session.ts, attach.ts) loads lazily on first
40
41
  // use — every ProviderModule method is already async, so this is invisible to
@@ -48,6 +49,13 @@ export const claudeProvider: ProviderModule = {
48
49
  transcript: claudeTranscriptOps,
49
50
  attachSession: async (record, opts) =>
50
51
  (await import("./attach.js")).attachClaudeSession(record, opts),
52
+ attachHistory: async (record, opts) => {
53
+ const [{ attachClaudeSession }, { historyFromSessionAttachment }] = await Promise.all([
54
+ import("./attach.js"),
55
+ import("../../sessions/history.js"),
56
+ ]);
57
+ return historyFromSessionAttachment("claude", await attachClaudeSession(record, opts));
58
+ },
51
59
  };
52
60
 
53
61
  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;
@@ -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,6 +38,44 @@ 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
@@ -48,6 +87,12 @@ export async function attachCodexSession(
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
  }
@@ -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
  }
@@ -23,6 +23,7 @@ export const codexProvider: ProviderModule = {
23
23
  modes: true,
24
24
  goals: codexGoalCapability,
25
25
  durableSessions: true,
26
+ durableHistory: true,
26
27
  },
27
28
  // Heavy machinery (execute.ts, session.ts, modes.ts, attach.ts) loads lazily
28
29
  // on first use — every ProviderModule method is already async.
@@ -35,6 +36,13 @@ export const codexProvider: ProviderModule = {
35
36
  listModes: async (opts) => (await import("./modes.js")).listCodexModes(opts),
36
37
  attachSession: async (record, opts) =>
37
38
  (await import("./attach.js")).attachCodexSession(record, opts),
39
+ attachHistory: async (record, opts) => {
40
+ const [{ attachCodexSession }, { historyFromSessionAttachment }] = await Promise.all([
41
+ import("./attach.js"),
42
+ import("../../sessions/history.js"),
43
+ ]);
44
+ return historyFromSessionAttachment("codex", await attachCodexSession(record, opts));
45
+ },
38
46
  };
39
47
 
40
48
  export {
@@ -619,6 +619,11 @@ export class CodexSessionImpl implements AgentSession {
619
619
  input: [{ type: "text", text: message }],
620
620
  };
621
621
  if (this._threadId) turnParams["threadId"] = this._threadId;
622
+ // Codex app-server accepts reasoning effort as a turn/start override. The
623
+ // selected value becomes the default for later turns on this thread, so
624
+ // forwarding the session config here works for both fresh and resumed
625
+ // sessions without mutating the user's global config.toml.
626
+ if (this.ctx.config?.effort) turnParams["effort"] = this.ctx.config.effort;
622
627
 
623
628
  let resolveFn!: (r: TurnResult) => void;
624
629
  let rejectFn!: (e: Error) => void;
@@ -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