@agentex/agent 0.0.38 → 0.0.40

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 (120) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/README.md +47 -13
  3. package/dist/history/fs.d.ts.map +1 -1
  4. package/dist/history/fs.js +6 -12
  5. package/dist/history/fs.js.map +1 -1
  6. package/dist/providers/antigravity/codec.d.ts +4 -0
  7. package/dist/providers/antigravity/codec.d.ts.map +1 -0
  8. package/dist/providers/antigravity/codec.js +27 -0
  9. package/dist/providers/antigravity/codec.js.map +1 -0
  10. package/dist/providers/antigravity/connection.d.ts +100 -0
  11. package/dist/providers/antigravity/connection.d.ts.map +1 -0
  12. package/dist/providers/antigravity/connection.js +267 -0
  13. package/dist/providers/antigravity/connection.js.map +1 -0
  14. package/dist/providers/antigravity/discovery.d.ts +11 -0
  15. package/dist/providers/antigravity/discovery.d.ts.map +1 -0
  16. package/dist/providers/antigravity/discovery.js +92 -0
  17. package/dist/providers/antigravity/discovery.js.map +1 -0
  18. package/dist/providers/antigravity/execute.d.ts +9 -0
  19. package/dist/providers/antigravity/execute.d.ts.map +1 -0
  20. package/dist/providers/antigravity/execute.js +206 -0
  21. package/dist/providers/antigravity/execute.js.map +1 -0
  22. package/dist/providers/antigravity/index.d.ts +12 -0
  23. package/dist/providers/antigravity/index.d.ts.map +1 -0
  24. package/dist/providers/antigravity/index.js +46 -0
  25. package/dist/providers/antigravity/index.js.map +1 -0
  26. package/dist/providers/antigravity/parse.d.ts +114 -0
  27. package/dist/providers/antigravity/parse.d.ts.map +1 -0
  28. package/dist/providers/antigravity/parse.js +369 -0
  29. package/dist/providers/antigravity/parse.js.map +1 -0
  30. package/dist/providers/antigravity/probe.d.ts +3 -0
  31. package/dist/providers/antigravity/probe.d.ts.map +1 -0
  32. package/dist/providers/antigravity/probe.js +84 -0
  33. package/dist/providers/antigravity/probe.js.map +1 -0
  34. package/dist/providers/antigravity/runtime.d.ts +30 -0
  35. package/dist/providers/antigravity/runtime.d.ts.map +1 -0
  36. package/dist/providers/antigravity/runtime.js +69 -0
  37. package/dist/providers/antigravity/runtime.js.map +1 -0
  38. package/dist/providers/antigravity/session.d.ts +7 -0
  39. package/dist/providers/antigravity/session.d.ts.map +1 -0
  40. package/dist/providers/antigravity/session.js +317 -0
  41. package/dist/providers/antigravity/session.js.map +1 -0
  42. package/dist/providers/claude/attach.d.ts.map +1 -1
  43. package/dist/providers/claude/attach.js +4 -6
  44. package/dist/providers/claude/attach.js.map +1 -1
  45. package/dist/providers/claude/transcript.d.ts.map +1 -1
  46. package/dist/providers/claude/transcript.js +9 -20
  47. package/dist/providers/claude/transcript.js.map +1 -1
  48. package/dist/providers/codex/attach.d.ts.map +1 -1
  49. package/dist/providers/codex/attach.js +4 -0
  50. package/dist/providers/codex/attach.js.map +1 -1
  51. package/dist/providers/codex/history.d.ts.map +1 -1
  52. package/dist/providers/codex/history.js +11 -0
  53. package/dist/providers/codex/history.js.map +1 -1
  54. package/dist/providers/codex/transcript-normalize.d.ts +12 -3
  55. package/dist/providers/codex/transcript-normalize.d.ts.map +1 -1
  56. package/dist/providers/codex/transcript-normalize.js +87 -10
  57. package/dist/providers/codex/transcript-normalize.js.map +1 -1
  58. package/dist/providers/codex/transcript.d.ts.map +1 -1
  59. package/dist/providers/codex/transcript.js +5 -10
  60. package/dist/providers/codex/transcript.js.map +1 -1
  61. package/dist/providers/codex/usage-scanner.d.ts.map +1 -1
  62. package/dist/providers/codex/usage-scanner.js +4 -6
  63. package/dist/providers/codex/usage-scanner.js.map +1 -1
  64. package/dist/providers/gemini/index.d.ts.map +1 -1
  65. package/dist/providers/gemini/index.js +6 -0
  66. package/dist/providers/gemini/index.js.map +1 -1
  67. package/dist/registry.d.ts.map +1 -1
  68. package/dist/registry.js +2 -0
  69. package/dist/registry.js.map +1 -1
  70. package/dist/types.d.ts +6 -5
  71. package/dist/types.d.ts.map +1 -1
  72. package/dist/types.js.map +1 -1
  73. package/dist/utils/auth.d.ts +1 -1
  74. package/dist/utils/auth.d.ts.map +1 -1
  75. package/dist/utils/auth.js +79 -1
  76. package/dist/utils/auth.js.map +1 -1
  77. package/dist/utils/binary.d.ts.map +1 -1
  78. package/dist/utils/binary.js +10 -0
  79. package/dist/utils/binary.js.map +1 -1
  80. package/dist/utils/instructions.d.ts.map +1 -1
  81. package/dist/utils/instructions.js +12 -9
  82. package/dist/utils/instructions.js.map +1 -1
  83. package/dist/utils/jsonl-lines.d.ts +35 -0
  84. package/dist/utils/jsonl-lines.d.ts.map +1 -0
  85. package/dist/utils/jsonl-lines.js +61 -0
  86. package/dist/utils/jsonl-lines.js.map +1 -0
  87. package/dist/utils/runtime-homes.d.ts.map +1 -1
  88. package/dist/utils/runtime-homes.js +4 -0
  89. package/dist/utils/runtime-homes.js.map +1 -1
  90. package/dist/utils/skills.d.ts +3 -3
  91. package/dist/utils/skills.d.ts.map +1 -1
  92. package/dist/utils/skills.js +16 -2
  93. package/dist/utils/skills.js.map +1 -1
  94. package/package.json +3 -2
  95. package/src/history/fs.ts +6 -12
  96. package/src/providers/antigravity/codec.ts +29 -0
  97. package/src/providers/antigravity/connection.ts +304 -0
  98. package/src/providers/antigravity/discovery.ts +90 -0
  99. package/src/providers/antigravity/execute.ts +214 -0
  100. package/src/providers/antigravity/index.ts +48 -0
  101. package/src/providers/antigravity/parse.ts +439 -0
  102. package/src/providers/antigravity/probe.ts +89 -0
  103. package/src/providers/antigravity/runtime.ts +64 -0
  104. package/src/providers/antigravity/session.ts +359 -0
  105. package/src/providers/claude/attach.ts +4 -6
  106. package/src/providers/claude/transcript.ts +9 -22
  107. package/src/providers/codex/attach.ts +4 -0
  108. package/src/providers/codex/history.ts +11 -0
  109. package/src/providers/codex/transcript-normalize.ts +85 -10
  110. package/src/providers/codex/transcript.ts +5 -11
  111. package/src/providers/codex/usage-scanner.ts +4 -7
  112. package/src/providers/gemini/index.ts +6 -0
  113. package/src/registry.ts +2 -0
  114. package/src/types.ts +6 -5
  115. package/src/utils/auth.ts +82 -1
  116. package/src/utils/binary.ts +10 -0
  117. package/src/utils/instructions.ts +10 -9
  118. package/src/utils/jsonl-lines.ts +80 -0
  119. package/src/utils/runtime-homes.ts +4 -0
  120. package/src/utils/skills.ts +17 -4
@@ -0,0 +1,359 @@
1
+ import type {
2
+ AgentSession,
3
+ CancelResult,
4
+ ClearGoalResult,
5
+ GoalOptions,
6
+ GoalState,
7
+ SendHandle,
8
+ SendOptions,
9
+ SessionContext,
10
+ SessionState,
11
+ SetGoalResult,
12
+ StopTaskResult,
13
+ StreamEvent,
14
+ TurnResult,
15
+ TurnTrigger,
16
+ } from "../../types.js";
17
+ import { GoalController, EMULATED_GOAL_CAPABILITY } from "../../goals/index.js";
18
+ import type { ResolvedBinary } from "../../utils/binary.js";
19
+ import { buildEnv, ensurePathInEnv } from "../../utils/env.js";
20
+ import { resolveInstructions } from "../../utils/instructions.js";
21
+ import { injectHomeSkills } from "../../utils/skills.js";
22
+ import { uuidv7 } from "../../utils/uuid.js";
23
+ import {
24
+ AgyConnection,
25
+ firstAgyErrorLine,
26
+ interruptAgyTurn,
27
+ type AgyTurnOutcome,
28
+ } from "./connection.js";
29
+ import {
30
+ ANTIGRAVITY_PROVIDER_TYPE,
31
+ AgyStreamParser,
32
+ agyUsageRecord,
33
+ classifyAgyFailure,
34
+ mapAgyTurnStatus,
35
+ } from "./parse.js";
36
+ import { AGY_SIGN_IN_MESSAGE, buildAgyArgs, findAgyBinary, readAgyResumeId } from "./runtime.js";
37
+
38
+ interface ActiveTurn {
39
+ turnId: string;
40
+ abort: AbortController;
41
+ done: Promise<TurnResult>;
42
+ }
43
+
44
+ /**
45
+ * Open a multi-turn Antigravity session. Throws when `agy` is not installed.
46
+ * The process starts on the first `send()`.
47
+ */
48
+ export async function createAntigravitySession(ctx: SessionContext): Promise<AgentSession> {
49
+ const binary = await findAgyBinary(ctx);
50
+ const config = ctx.config ?? {};
51
+ const instructions = await resolveInstructions(config.instructionsFile);
52
+ if (config.skillDirs && config.skillDirs.length > 0) {
53
+ try {
54
+ await injectHomeSkills(config.skillDirs, "antigravity");
55
+ } catch {
56
+ // Non-fatal
57
+ }
58
+ }
59
+ return new AntigravitySession(ctx, binary, instructions);
60
+ }
61
+
62
+ /**
63
+ * One `agy --input-format stream-json --output-format stream-json` process
64
+ * holds the conversation across turns. The CLI has no control protocol, so
65
+ * an interrupt is a SIGINT (Ctrl+C); when that ends the process, the next
66
+ * `send()` starts a new one on the same conversation with `--conversation`.
67
+ */
68
+ class AntigravitySession implements AgentSession {
69
+ private _state: SessionState = "idle";
70
+ private conversationId: string | null;
71
+ private connection: AgyConnection | null = null;
72
+ private parser: AgyStreamParser | null = null;
73
+ private active: ActiveTurn | null = null;
74
+ /** Events received from agy processes, to tell whether one saw a message. */
75
+ private connectionEvents = 0;
76
+ private draining = false;
77
+ /** Instructions go into the first message of a conversation this session starts. */
78
+ private pendingInstructions: string | null;
79
+ private readonly cwd: string;
80
+ private readonly goals: GoalController;
81
+
82
+ constructor(
83
+ private readonly ctx: SessionContext,
84
+ private readonly binary: ResolvedBinary,
85
+ instructions: string | null,
86
+ ) {
87
+ this.cwd = ctx.cwd ?? process.cwd();
88
+ this.conversationId = readAgyResumeId(ctx.sessionParams, this.cwd);
89
+ this.pendingInstructions = this.conversationId ? null : instructions;
90
+ this.goals = new GoalController({
91
+ providerType: ANTIGRAVITY_PROVIDER_TYPE,
92
+ capability: EMULATED_GOAL_CAPABILITY,
93
+ getSessionId: () => this.sessionId,
94
+ send: (message) => this.send(message),
95
+ dispatch: (event) => void this.dispatch(event),
96
+ });
97
+ if (ctx.signal) {
98
+ if (ctx.signal.aborted) void this.close();
99
+ else ctx.signal.addEventListener("abort", () => void this.close(), { once: true });
100
+ }
101
+ }
102
+
103
+ get sessionId(): string | null {
104
+ return this.parser?.conversationId ?? this.conversationId;
105
+ }
106
+
107
+ get state(): SessionState {
108
+ return this._state;
109
+ }
110
+
111
+ async send(message: string, options?: SendOptions): Promise<SendHandle> {
112
+ if (this._state === "closed") throw new Error("Session is closed");
113
+ if (this.draining) throw new Error("Session is draining — no new sends accepted");
114
+ if (this.active) {
115
+ throw new Error("Antigravity session is busy — a turn is already in progress (concurrentSend not supported)");
116
+ }
117
+
118
+ const uuid = uuidv7();
119
+ const turnId = uuidv7();
120
+ const abort = new AbortController();
121
+ const text = this.pendingInstructions ? `${this.pendingInstructions}\n\n${message}` : message;
122
+ this.pendingInstructions = null;
123
+
124
+ this._state = "thinking";
125
+ const done = this.runTurn(text, turnId, abort, options);
126
+ this.active = { turnId, abort, done };
127
+ void done.then((result) => this.goals.onTurnSettled(result)).catch(() => undefined);
128
+ return { uuid, result: done };
129
+ }
130
+
131
+ private ensureConnection(): AgyConnection {
132
+ if (this.connection?.alive) return this.connection;
133
+ const config = this.ctx.config ?? {};
134
+ // A replacement process picks the conversation back up where it stopped.
135
+ this.conversationId = this.sessionId;
136
+ const parser = new AgyStreamParser({
137
+ includePartialMessages: config.includePartialMessages,
138
+ conversationId: this.conversationId,
139
+ });
140
+ const env = buildEnv(this.ctx.env);
141
+ ensurePathInEnv(env);
142
+ this.ctx.onLifecycle?.({ phase: "spawning" });
143
+ const connection = new AgyConnection({
144
+ command: this.binary.bin,
145
+ args: [
146
+ ...this.binary.prefixArgs,
147
+ ...buildAgyArgs(config, { resumeId: this.conversationId, model: config.model ?? null }),
148
+ ],
149
+ cwd: this.cwd,
150
+ env,
151
+ parser,
152
+ // Lines a replaced process prints on its way out belong to no turn.
153
+ onEvent: async (event): Promise<void> => {
154
+ if (this.connection === connection) await this.onEvent(event);
155
+ },
156
+ ...(this.ctx.onOutput ? { onOutput: this.ctx.onOutput } : {}),
157
+ });
158
+ if (connection.pid != null) this.ctx.onLifecycle?.({ phase: "running", pid: connection.pid });
159
+ this.connection = connection;
160
+ this.parser = parser;
161
+ return connection;
162
+ }
163
+
164
+ private async runTurn(
165
+ text: string,
166
+ turnId: string,
167
+ abort: AbortController,
168
+ options?: SendOptions,
169
+ ): Promise<TurnResult> {
170
+ await this.emitTurnEdge({ type: "turn_start", turnId, trigger: "send" });
171
+
172
+ const timeoutSec = options?.timeoutSec ?? this.ctx.config?.timeoutSec;
173
+ const graceSec = this.ctx.config?.graceSec;
174
+ let timer: ReturnType<typeof setTimeout> | null = null;
175
+ const guards: Promise<"timeout" | "aborted">[] = [
176
+ new Promise((resolve) => abort.signal.addEventListener("abort", () => resolve("aborted"), { once: true })),
177
+ ];
178
+ if (timeoutSec && timeoutSec > 0) {
179
+ guards.push(new Promise((resolve) => { timer = setTimeout(() => resolve("timeout"), timeoutSec * 1000); }));
180
+ }
181
+ const onSendAbort = () => abort.abort();
182
+ if (options?.signal) {
183
+ if (options.signal.aborted) abort.abort();
184
+ else options.signal.addEventListener("abort", onSendAbort, { once: true });
185
+ }
186
+
187
+ let connection = this.ensureConnection();
188
+ try {
189
+ let result: TurnResult | null = null;
190
+ let reason: "result" | "cancelled" | "session_closed" = "result";
191
+ for (let attempt = 0; result === null; attempt++) {
192
+ const eventsBefore = this.connectionEvents;
193
+ const pending = connection.nextTurn();
194
+ connection.write(text);
195
+ const first = await Promise.race([pending, ...guards]);
196
+ if (first === "timeout" || first === "aborted") {
197
+ const outcome = await interruptAgyTurn(connection, pending, graceSec);
198
+ // agy may report INTERRUPTED and then exit. Learn which before the
199
+ // next send, so it never lands on a process that is going away.
200
+ if (outcome?.result) await connection.waitForExit(500);
201
+ // Without an INTERRUPTED result the process was stopped; a late result
202
+ // from it must never settle the next turn.
203
+ if ((!outcome?.result || !connection.alive) && this.connection === connection) this.connection = null;
204
+ result = {
205
+ summary: outcome?.result?.response.trim() || null,
206
+ ...this.usageOf(outcome),
207
+ costUsd: null,
208
+ status: first,
209
+ errorCode: first,
210
+ errorMessage: first === "timeout" ? "Turn exceeded its timeout" : "Turn aborted",
211
+ };
212
+ reason = "cancelled";
213
+ } else if (
214
+ attempt === 0 && !first.result && first.exited && !first.authRequired
215
+ && this.connectionEvents === eventsBefore && this._state !== "closed"
216
+ ) {
217
+ // The process exited without ever acknowledging the message (it was
218
+ // already shutting down). Run it on a fresh process for the same
219
+ // conversation instead of failing a turn agy never started.
220
+ if (this.connection === connection) this.connection = null;
221
+ connection = this.ensureConnection();
222
+ } else {
223
+ result = this.toTurnResult(first);
224
+ reason = first.result ? "result" : "session_closed";
225
+ }
226
+ }
227
+ await this.emitTurnEdge({ type: "turn_end", turnId, trigger: "send", reason });
228
+ return result;
229
+ } finally {
230
+ if (timer) clearTimeout(timer);
231
+ options?.signal?.removeEventListener("abort", onSendAbort);
232
+ this.active = null;
233
+ if (!connection.alive && this.connection === connection) this.connection = null;
234
+ if (this._state !== "closed") this._state = "idle";
235
+ }
236
+ }
237
+
238
+ private usageOf(outcome: AgyTurnOutcome | null): { usage?: TurnResult["usage"] } {
239
+ const usage = agyUsageRecord(outcome?.result?.turnUsage ?? null, this.parser?.model ?? this.ctx.config?.model ?? null);
240
+ return usage ? { usage } : {};
241
+ }
242
+
243
+ private toTurnResult(outcome: AgyTurnOutcome): TurnResult {
244
+ const { result } = outcome;
245
+ if (result) {
246
+ const status = mapAgyTurnStatus(result.status);
247
+ if (status === "completed") {
248
+ return { summary: result.response.trim() || null, ...this.usageOf(outcome), costUsd: null, status, errorCode: null, errorMessage: null };
249
+ }
250
+ const errorCode = status === "aborted"
251
+ ? "aborted"
252
+ : outcome.authRequired ? "auth_required" : classifyAgyFailure(result, outcome.agyError);
253
+ return {
254
+ summary: result.response.trim() || null,
255
+ ...this.usageOf(outcome),
256
+ costUsd: null,
257
+ status,
258
+ errorCode,
259
+ errorMessage: errorCode === "auth_required"
260
+ ? AGY_SIGN_IN_MESSAGE
261
+ : result.error ?? outcome.agyError?.message ?? `Antigravity turn ended with ${result.status}`,
262
+ };
263
+ }
264
+ if (outcome.authRequired) {
265
+ return { summary: null, costUsd: null, status: "failed", errorCode: "auth_required", errorMessage: AGY_SIGN_IN_MESSAGE };
266
+ }
267
+ return {
268
+ summary: null,
269
+ costUsd: null,
270
+ status: "failed",
271
+ errorCode: "process_exited",
272
+ errorMessage: outcome.agyError?.message
273
+ ?? firstAgyErrorLine(outcome.stderr)
274
+ ?? `Antigravity CLI exited (code ${outcome.exitCode ?? "null"}${outcome.signal ? `, ${outcome.signal}` : ""}) before finishing the turn`,
275
+ };
276
+ }
277
+
278
+ private async onEvent(event: StreamEvent): Promise<void> {
279
+ this.connectionEvents++;
280
+ if (this._state !== "closed") {
281
+ if (event.type === "tool_call") this._state = "tool_executing";
282
+ else if (event.type === "assistant" || event.type === "thinking" || event.type === "tool_result") this._state = "thinking";
283
+ }
284
+ await this.dispatch(event);
285
+ }
286
+
287
+ private async emitTurnEdge(
288
+ edge:
289
+ | { type: "turn_start"; turnId: string; trigger: TurnTrigger }
290
+ | { type: "turn_end"; turnId: string; trigger: TurnTrigger; reason: "result" | "cancelled" | "session_closed" },
291
+ ): Promise<void> {
292
+ await this.dispatch({
293
+ ...edge,
294
+ timestamp: new Date().toISOString(),
295
+ providerType: ANTIGRAVITY_PROVIDER_TYPE,
296
+ sessionId: this.sessionId,
297
+ messageId: null,
298
+ eventId: null,
299
+ parentToolCallId: null,
300
+ raw: {},
301
+ } as StreamEvent);
302
+ }
303
+
304
+ private async dispatch(event: StreamEvent): Promise<void> {
305
+ if (!this.ctx.onEvent) return;
306
+ try {
307
+ await this.ctx.onEvent(event);
308
+ } catch {
309
+ /* a throwing handler must not break the stream */
310
+ }
311
+ }
312
+
313
+ async cancel(_uuid: string): Promise<CancelResult> {
314
+ // One message per turn and no queue, so there is never a queued message to drop.
315
+ return { cancelled: false };
316
+ }
317
+
318
+ async stopTask(_taskId: string): Promise<StopTaskResult> {
319
+ return { stopped: false };
320
+ }
321
+
322
+ setGoal(objective: string, options?: GoalOptions): Promise<SetGoalResult> {
323
+ return this.goals.setGoal(objective, options);
324
+ }
325
+
326
+ clearGoal(options?: { reason?: "cleared" | "blocked" }): Promise<ClearGoalResult> {
327
+ return this.goals.clearGoal(options);
328
+ }
329
+
330
+ getGoal(): GoalState | null {
331
+ return this.goals.getGoal();
332
+ }
333
+
334
+ async interrupt(): Promise<void> {
335
+ const active = this.active;
336
+ if (!active) return;
337
+ active.abort.abort();
338
+ await active.done.catch(() => undefined);
339
+ }
340
+
341
+ async drain(): Promise<void> {
342
+ this.draining = true;
343
+ if (this.active) await this.active.done.catch(() => undefined);
344
+ await this.close();
345
+ }
346
+
347
+ async close(): Promise<void> {
348
+ if (this._state === "closed" && !this.connection) return;
349
+ this._state = "closed";
350
+ const active = this.active;
351
+ if (active) {
352
+ active.abort.abort();
353
+ await active.done.catch(() => undefined);
354
+ }
355
+ const connection = this.connection;
356
+ this.connection = null;
357
+ if (connection) await connection.terminate(this.ctx.config?.graceSec);
358
+ }
359
+ }
@@ -1,6 +1,5 @@
1
1
  import { createReadStream } from "node:fs";
2
2
  import { stat } from "node:fs/promises";
3
- import * as readline from "node:readline";
4
3
  import type {
5
4
  AttachOptions,
6
5
  CatchUpOptions,
@@ -16,6 +15,7 @@ import {
16
15
  createSessionRecord,
17
16
  MalformedSessionRecordError,
18
17
  } from "../../sessions/record.js";
18
+ import { readJsonlLines } from "../../utils/jsonl-lines.js";
19
19
  import { getRuntimeHomeEnvVar } from "../../utils/runtime-homes.js";
20
20
  import { claudeSessionCodec } from "./codec.js";
21
21
  import {
@@ -86,16 +86,15 @@ const EMPTY: AsyncIterable<CatchUpYield> = {
86
86
  * preceding completed one.
87
87
  */
88
88
  async function classifyLastTurn(filePath: string): Promise<LastTurnStatus> {
89
- const stream = createReadStream(filePath, { encoding: "utf8" });
89
+ const stream = createReadStream(filePath);
90
90
  stream.on("error", () => {});
91
- const lines = readline.createInterface({ input: stream, crlfDelay: Infinity });
92
91
  let lastTurn: LastTurnStatus = "unknown";
93
92
 
94
93
  try {
95
- for await (const line of lines) {
94
+ for await (const line of readJsonlLines(stream)) {
96
95
  let raw: unknown;
97
96
  try {
98
- raw = JSON.parse(line);
97
+ raw = JSON.parse(line.text);
99
98
  } catch {
100
99
  continue;
101
100
  }
@@ -109,7 +108,6 @@ async function classifyLastTurn(filePath: string): Promise<LastTurnStatus> {
109
108
  if (fsError?.code !== "ENOENT") throw error;
110
109
  return "unknown";
111
110
  } finally {
112
- lines.close();
113
111
  stream.destroy();
114
112
  }
115
113
 
@@ -23,8 +23,8 @@ import { createReadStream } from "node:fs";
23
23
  import { readdir, realpath, stat, open as fsOpen } from "node:fs/promises";
24
24
  import * as os from "node:os";
25
25
  import * as path from "node:path";
26
- import * as readline from "node:readline";
27
26
 
27
+ import { readJsonlLines } from "../../utils/jsonl-lines.js";
28
28
  import { getDefaultRuntimeHome, getRuntimeHomeEnvVar } from "../../utils/runtime-homes.js";
29
29
  import type { FoundTranscript, StreamEvent, TranscriptOps } from "../../types.js";
30
30
  import { parseStreamLine } from "./parse.js";
@@ -273,13 +273,12 @@ async function readCwdFromTranscript(filePath: string): Promise<string | null> {
273
273
  if (!fh) return null;
274
274
 
275
275
  try {
276
- const stream = fh.createReadStream({ encoding: "utf8", autoClose: false });
277
- const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
276
+ const stream = fh.createReadStream({ autoClose: false });
278
277
  let count = 0;
279
278
  try {
280
- for await (const raw of rl) {
279
+ for await (const line of readJsonlLines(stream)) {
281
280
  if (++count > 50) break;
282
- const trimmed = raw.trim();
281
+ const trimmed = line.text.trim();
283
282
  if (!trimmed) continue;
284
283
  try {
285
284
  const obj = JSON.parse(trimmed) as Record<string, unknown>;
@@ -300,7 +299,6 @@ async function readCwdFromTranscript(filePath: string): Promise<string | null> {
300
299
  }
301
300
  }
302
301
  } finally {
303
- rl.close();
304
302
  stream.destroy();
305
303
  }
306
304
  } finally {
@@ -377,26 +375,16 @@ export async function* readClaudeTranscript(
377
375
 
378
376
  const stream = createReadStream(filePath, { start: fromOffset, encoding: undefined });
379
377
 
380
- // Suppress stray ENOENT (file deleted between stat and open) — readline
378
+ // Suppress stray ENOENT (file deleted between stat and open) — the stream
381
379
  // raises the same condition through its own iterator, which we catch below.
382
380
  stream.on("error", () => {});
383
381
 
384
- const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
385
-
386
- let pos = fromOffset;
387
382
  let stillSkippingPastSince = !!sinceEventId;
388
383
 
389
384
  try {
390
- for await (const line of rl) {
391
- // readline strips the trailing `\n` (and the `\r` from `\r\n`). Claude
392
- // writes Unix line endings, so we account for `\n` only. A `\r\n` file
393
- // would yield offsets 1 byte short per line — accepted as a corner
394
- // case; resume from such an offset would skip one stray `\r`.
395
- const lineByteLen = Buffer.byteLength(line, "utf8");
396
- pos += lineByteLen + 1;
397
-
398
- if (!line) continue;
399
- const trimmed = line.trim();
385
+ for await (const line of readJsonlLines(stream, fromOffset)) {
386
+ if (!line.text) continue;
387
+ const trimmed = line.text.trim();
400
388
  if (!trimmed) continue;
401
389
 
402
390
  if (looksLikeSkippedType(trimmed)) continue;
@@ -413,7 +401,7 @@ export async function* readClaudeTranscript(
413
401
  }
414
402
 
415
403
  for (const event of events) {
416
- yield { event, offset: pos };
404
+ yield { event, offset: line.end };
417
405
  }
418
406
  }
419
407
  } catch (err) {
@@ -421,7 +409,6 @@ export async function* readClaudeTranscript(
421
409
  const e = err as NodeJS.ErrnoException;
422
410
  if (e?.code !== "ENOENT") throw err;
423
411
  } finally {
424
- rl.close();
425
412
  stream.destroy();
426
413
  }
427
414
  }
@@ -132,6 +132,10 @@ export async function attachCodexSession(
132
132
  if (transcript) {
133
133
  const lastEvent = await latestTurnBoundary(transcript.filePath, sessionId);
134
134
  if (lastEvent === null) lastTurn = "unknown";
135
+ // turn_aborted replays as an interrupted result, but the turn never
136
+ // finished, so it stays "interrupted" for hosts deciding whether to re-send.
137
+ else if (lastEvent.type === "event_msg" && lastEvent.payload?.["type"] === "turn_aborted")
138
+ lastTurn = "interrupted";
135
139
  else if (codexLineToStreamEvents(lastEvent, { sessionId }).some((event) => event.type === "result"))
136
140
  lastTurn = "completed";
137
141
  else lastTurn = "interrupted";
@@ -57,6 +57,17 @@ function codexUserText(line: CodexTranscriptLine): string | null {
57
57
  if (line.type === "event_msg" && line.payload?.["type"] === "user_message") {
58
58
  return meaningfulHumanText(asString(line.payload["message"]));
59
59
  }
60
+ // Paginated rollouts (`session_meta.history_mode: "paginated"`, written by
61
+ // Codex 0.142 and later) never write `user_message`. What the person typed
62
+ // is a completed `UserMessage` item instead, and legacy rollouts never
63
+ // persist that item, so each message is read once. The `response_item` user
64
+ // message beside it also carries injected context, so it is not the one read.
65
+ if (line.type === "event_msg" && line.payload?.["type"] === "item_completed") {
66
+ const item = asRecord(line.payload["item"]);
67
+ if (item?.["type"] === "UserMessage") {
68
+ return meaningfulHumanText(textFromContent(item["content"], new Set(["text", "input_text"])));
69
+ }
70
+ }
60
71
  // Older unwrapped rollouts have no event_msg mirror.
61
72
  if (line.type === "message" && line.raw["role"] === "user") {
62
73
  return meaningfulHumanText(textFromContent(
@@ -12,11 +12,20 @@ import type { CodexTranscriptLine } from "./transcript.js";
12
12
  * response_item / reasoning → thinking
13
13
  * response_item / function_call → tool_call
14
14
  * response_item / function_call_output → tool_result
15
- * event_msg / task_complete → result (completed)
15
+ * response_item / custom_tool_call → tool_call
16
+ * response_item / custom_tool_call_output → tool_result
17
+ * event_msg / task_complete → result (completed, or failed with `error`)
18
+ * event_msg / turn_aborted → result (interrupted)
19
+ *
20
+ * Current Codex runs shell work through freeform tools: `exec` takes a script
21
+ * and `apply_patch` takes a patch, so most tool activity in a current rollout
22
+ * is a `custom_tool_call`. Its `input` is the raw string the model wrote.
16
23
  *
17
24
  * Everything else — `session_meta`, `turn_context`, `task_started`,
18
- * `token_count`, `agent_message`/`agent_reasoning` duplicates, user/developer
19
- * messages, unwrapped legacy lines, unknown types — yields `[]`.
25
+ * `token_count`, `agent_message`/`agent_reasoning` duplicates, the
26
+ * `item_completed` mirrors of response items, inter-agent `agent_message`
27
+ * items, user/developer messages, unwrapped legacy lines, unknown types —
28
+ * yields `[]`.
20
29
  *
21
30
  * The on-disk vocabulary is Codex-internal and version-shifting, so every field
22
31
  * is read defensively and a weird line NEVER throws — it returns `[]`. Codex
@@ -89,7 +98,19 @@ function mapLine(line: CodexTranscriptLine, sessionId: string | null): StreamEve
89
98
  ];
90
99
  }
91
100
 
92
- if (innerType === "function_call_output") {
101
+ if (innerType === "custom_tool_call") {
102
+ return [
103
+ {
104
+ type: "tool_call",
105
+ toolCallId: str(payload["call_id"]) ?? str(payload["id"]),
106
+ name: str(payload["name"]) ?? "custom_tool_call",
107
+ input: typeof payload["input"] === "string" ? payload["input"] : null,
108
+ ...base,
109
+ },
110
+ ];
111
+ }
112
+
113
+ if (innerType === "function_call_output" || innerType === "custom_tool_call_output") {
93
114
  return [
94
115
  {
95
116
  type: "tool_result",
@@ -110,16 +131,36 @@ function mapLine(line: CodexTranscriptLine, sessionId: string | null): StreamEve
110
131
 
111
132
  if (line.type === "event_msg") {
112
133
  if (innerType === "task_complete") {
134
+ // A turn that failed (for example a model the account cannot use) still
135
+ // ends in task_complete, with `error` set and no agent message.
136
+ const error = turnErrorMessage(payload["error"]);
137
+ return [
138
+ {
139
+ type: "result",
140
+ text: error ?? str(payload["last_agent_message"]) ?? "",
141
+ costUsd: null,
142
+ isError: error !== null,
143
+ stopReason: null,
144
+ terminalReason: error !== null ? "failed" : "completed",
145
+ numTurns: null,
146
+ durationMs: num(payload["duration_ms"]),
147
+ ...base,
148
+ },
149
+ ];
150
+ }
151
+
152
+ if (innerType === "turn_aborted") {
153
+ // Same shape as a live `turn/completed` with status "interrupted".
113
154
  return [
114
155
  {
115
156
  type: "result",
116
- text: str(payload["last_agent_message"]) ?? "",
157
+ text: "",
117
158
  costUsd: null,
118
159
  isError: false,
119
160
  stopReason: null,
120
- terminalReason: "completed",
161
+ terminalReason: str(payload["reason"]) ?? "interrupted",
121
162
  numTurns: null,
122
- durationMs: null,
163
+ durationMs: num(payload["duration_ms"]),
123
164
  ...base,
124
165
  },
125
166
  ];
@@ -135,6 +176,28 @@ function str(v: unknown): string | null {
135
176
  return typeof v === "string" && v.length > 0 ? v : null;
136
177
  }
137
178
 
179
+ /** Finite number or null. */
180
+ function num(v: unknown): number | null {
181
+ return typeof v === "number" && Number.isFinite(v) ? v : null;
182
+ }
183
+
184
+ /**
185
+ * `task_complete.error` is `{ message, codex_error_info }`. The message is
186
+ * often the API's JSON error body, so prefer its inner `error.message`.
187
+ */
188
+ function turnErrorMessage(error: unknown): string | null {
189
+ if (typeof error === "string") return str(error);
190
+ if (typeof error !== "object" || error === null) return null;
191
+ const message = str((error as Record<string, unknown>)["message"]);
192
+ if (!message) return "Turn failed";
193
+ try {
194
+ const body = JSON.parse(message) as { error?: { message?: unknown }; message?: unknown };
195
+ return str(body?.error?.message) ?? str(body?.message) ?? message;
196
+ } catch {
197
+ return message;
198
+ }
199
+ }
200
+
138
201
  function messagePhase(v: unknown): "commentary" | "final_answer" | undefined {
139
202
  return v === "commentary" || v === "final_answer" ? v : undefined;
140
203
  }
@@ -171,13 +234,25 @@ function extractReasoningSummary(summary: unknown): string | null {
171
234
  }
172
235
 
173
236
  /**
174
- * `function_call_output.output` is usually a string, but some versions wrap it
175
- * as `{ output: "...", metadata: {...} }`. Extract the readable text; fall back
176
- * to a JSON dump so nothing is silently lost. Returns "" when unreadable
237
+ * Tool output is usually a string. Some versions wrap it as
238
+ * `{ output: "...", metadata: {...} }`, and Codex 0.142+ also writes a list of
239
+ * content parts (`input_text`, `input_image`). Extract the readable text, one
240
+ * part per line, with images left in `raw`. Fall back to a JSON dump
241
+ * so nothing is silently lost. Returns "" when unreadable
177
242
  * (`tool_result.content` is a required string).
178
243
  */
179
244
  function extractOutputText(output: unknown): string {
180
245
  if (typeof output === "string") return output;
246
+ if (Array.isArray(output)) {
247
+ let text = "";
248
+ for (const part of output) {
249
+ if (typeof part !== "object" || part === null) continue;
250
+ const partText = (part as { text?: unknown }).text;
251
+ if (typeof partText !== "string" || partText.length === 0) continue;
252
+ text += text && !text.endsWith("\n") ? `\n${partText}` : partText;
253
+ }
254
+ return text;
255
+ }
181
256
  if (output && typeof output === "object" && !Array.isArray(output)) {
182
257
  const inner = (output as Record<string, unknown>)["output"];
183
258
  if (typeof inner === "string") return inner;