@ottocode/sdk 0.1.274 → 0.1.276

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ottocode/sdk",
3
- "version": "0.1.274",
3
+ "version": "0.1.276",
4
4
  "description": "AI agent SDK for building intelligent assistants - tree-shakable and comprehensive",
5
5
  "author": "nitishxyz",
6
6
  "license": "MIT",
@@ -2,7 +2,12 @@ import { readFile, writeFile } from 'node:fs/promises';
2
2
  import { tool, type Tool } from 'ai';
3
3
  import { z } from 'zod/v3';
4
4
  import DESCRIPTION from './copy-into.txt' with { type: 'text' };
5
- import { buildWriteArtifact, isAbsoluteLike, resolveSafePath } from './util.ts';
5
+ import {
6
+ buildMutationMetadata,
7
+ buildWriteArtifact,
8
+ isAbsoluteLike,
9
+ resolveSafePath,
10
+ } from './util.ts';
6
11
  import {
7
12
  convertToLineEnding,
8
13
  detectLineEnding,
@@ -199,6 +204,7 @@ export function buildCopyIntoTool(projectRoot: string): {
199
204
  inputSchema: copyIntoSchema,
200
205
  async execute(input: CopyIntoInput): Promise<
201
206
  ToolResponse<{
207
+ operation: 'copy_into';
202
208
  sourcePath: string;
203
209
  targetPath: string;
204
210
  sourceRange: string;
@@ -206,6 +212,10 @@ export function buildCopyIntoTool(projectRoot: string): {
206
212
  mode: string;
207
213
  linesCopied: number;
208
214
  bytes: number;
215
+ bytesWritten: number;
216
+ changed: boolean;
217
+ sha256: string;
218
+ summary: { files: number; additions: number; deletions: number };
209
219
  artifact: unknown;
210
220
  }>
211
221
  > {
@@ -271,6 +281,7 @@ export function buildCopyIntoTool(projectRoot: string): {
271
281
 
272
282
  await writeFile(targetAbs, nextContent, 'utf-8');
273
283
  await rememberFileWrite(projectRoot, targetAbs);
284
+ const metadata = buildMutationMetadata(targetContent, nextContent);
274
285
  const artifact = await buildWriteArtifact(
275
286
  input.targetPath,
276
287
  true,
@@ -279,13 +290,18 @@ export function buildCopyIntoTool(projectRoot: string): {
279
290
  );
280
291
  return {
281
292
  ok: true,
293
+ operation: 'copy_into',
282
294
  sourcePath: input.sourcePath,
283
295
  targetPath: input.targetPath,
284
296
  sourceRange: `${input.startLine}-${sourceRange.endLine}`,
285
297
  targetRange: applied.targetRange,
286
298
  mode: input.mode ?? 'insert_before',
287
299
  linesCopied: sourceRange.copied.length,
288
- bytes: nextContent.length,
300
+ bytes: metadata.bytesWritten,
301
+ bytesWritten: metadata.bytesWritten,
302
+ changed: metadata.changed,
303
+ sha256: metadata.sha256,
304
+ summary: metadata.summary,
289
305
  artifact,
290
306
  };
291
307
  } catch (error: unknown) {
@@ -2,7 +2,12 @@ import { readFile, writeFile } from 'node:fs/promises';
2
2
  import { tool, type Tool } from 'ai';
3
3
  import { z } from 'zod/v3';
4
4
  import DESCRIPTION from './edit.txt' with { type: 'text' };
5
- import { buildWriteArtifact, isAbsoluteLike, resolveSafePath } from './util.ts';
5
+ import {
6
+ buildMutationMetadata,
7
+ buildWriteArtifact,
8
+ isAbsoluteLike,
9
+ resolveSafePath,
10
+ } from './util.ts';
6
11
  import { applyStringEdit } from './edit-shared.ts';
7
12
  import { assertFreshRead, rememberFileWrite } from './read-tracker.ts';
8
13
  import { createToolError, type ToolResponse } from '../../error.ts';
@@ -42,8 +47,13 @@ export function buildEditTool(projectRoot: string): {
42
47
  }): Promise<
43
48
  ToolResponse<{
44
49
  path: string;
50
+ operation: 'edit';
45
51
  occurrences: number;
46
52
  bytes: number;
53
+ bytesWritten: number;
54
+ changed: boolean;
55
+ sha256: string;
56
+ summary: { files: number; additions: number; deletions: number };
47
57
  artifact: unknown;
48
58
  }>
49
59
  > {
@@ -89,6 +99,7 @@ export function buildEditTool(projectRoot: string): {
89
99
 
90
100
  await writeFile(abs, updated.content, 'utf-8');
91
101
  await rememberFileWrite(projectRoot, abs);
102
+ const metadata = buildMutationMetadata(original, updated.content);
92
103
  const artifact = await buildWriteArtifact(
93
104
  path,
94
105
  true,
@@ -98,8 +109,13 @@ export function buildEditTool(projectRoot: string): {
98
109
  return {
99
110
  ok: true,
100
111
  path,
112
+ operation: 'edit',
101
113
  occurrences: updated.occurrences,
102
- bytes: updated.content.length,
114
+ bytes: metadata.bytesWritten,
115
+ bytesWritten: metadata.bytesWritten,
116
+ changed: metadata.changed,
117
+ sha256: metadata.sha256,
118
+ summary: metadata.summary,
103
119
  artifact,
104
120
  };
105
121
  } catch (error: unknown) {
@@ -2,7 +2,12 @@ import { readFile, writeFile } from 'node:fs/promises';
2
2
  import { tool, type Tool } from 'ai';
3
3
  import { z } from 'zod/v3';
4
4
  import DESCRIPTION from './multiedit.txt' with { type: 'text' };
5
- import { buildWriteArtifact, isAbsoluteLike, resolveSafePath } from './util.ts';
5
+ import {
6
+ buildMutationMetadata,
7
+ buildWriteArtifact,
8
+ isAbsoluteLike,
9
+ resolveSafePath,
10
+ } from './util.ts';
6
11
  import { applyStringEdit } from './edit-shared.ts';
7
12
  import { assertFreshRead, rememberFileWrite } from './read-tracker.ts';
8
13
  import { createToolError, type ToolResponse } from '../../error.ts';
@@ -39,8 +44,13 @@ export function buildMultiEditTool(projectRoot: string): {
39
44
  async execute({ path, edits }: z.infer<typeof multiEditSchema>): Promise<
40
45
  ToolResponse<{
41
46
  path: string;
47
+ operation: 'multiedit';
42
48
  editsApplied: number;
43
49
  bytes: number;
50
+ bytesWritten: number;
51
+ changed: boolean;
52
+ sha256: string;
53
+ summary: { files: number; additions: number; deletions: number };
44
54
  artifact: unknown;
45
55
  }>
46
56
  > {
@@ -97,6 +107,7 @@ export function buildMultiEditTool(projectRoot: string): {
97
107
 
98
108
  await writeFile(abs, nextContent, 'utf-8');
99
109
  await rememberFileWrite(projectRoot, abs);
110
+ const metadata = buildMutationMetadata(original, nextContent);
100
111
  const artifact = await buildWriteArtifact(
101
112
  path,
102
113
  true,
@@ -106,8 +117,13 @@ export function buildMultiEditTool(projectRoot: string): {
106
117
  return {
107
118
  ok: true,
108
119
  path,
120
+ operation: 'multiedit',
109
121
  editsApplied: edits.length,
110
- bytes: nextContent.length,
122
+ bytes: metadata.bytesWritten,
123
+ bytesWritten: metadata.bytesWritten,
124
+ changed: metadata.changed,
125
+ sha256: metadata.sha256,
126
+ summary: metadata.summary,
111
127
  artifact,
112
128
  };
113
129
  } catch (error: unknown) {
@@ -1,5 +1,6 @@
1
- import { createTwoFilesPatch } from 'diff';
1
+ import { createHash } from 'node:crypto';
2
2
  import { resolve as resolvePath } from 'node:path';
3
+ import { createTwoFilesPatch, diffLines } from 'diff';
3
4
 
4
5
  function normalizeForComparison(value: string) {
5
6
  const withForwardSlashes = value.replace(/\\/g, '/');
@@ -36,6 +37,17 @@ export function isAbsoluteLike(p: string): boolean {
36
37
  return p.startsWith('/') || /^[A-Za-z]:[\\/]/.test(p);
37
38
  }
38
39
 
40
+ export function buildMutationMetadata(oldText: string, newText: string) {
41
+ const bytesWritten = Buffer.byteLength(newText, 'utf-8');
42
+ const { additions, deletions } = summarizeTextChanges(oldText, newText);
43
+ return {
44
+ bytesWritten,
45
+ changed: oldText !== newText,
46
+ sha256: createHash('sha256').update(newText).digest('hex'),
47
+ summary: { files: 1, additions, deletions },
48
+ } as const;
49
+ }
50
+
39
51
  export async function buildWriteArtifact(
40
52
  relPath: string,
41
53
  existed: boolean,
@@ -67,7 +79,7 @@ export async function buildWriteArtifact(
67
79
  lines.push('*** End Patch');
68
80
  patch = lines.join('\n');
69
81
  }
70
- const { additions, deletions } = summarizePatchCounts(patch);
82
+ const { additions, deletions } = summarizeTextChanges(oldText, newText);
71
83
  return {
72
84
  kind: 'file_diff',
73
85
  patch,
@@ -75,21 +87,26 @@ export async function buildWriteArtifact(
75
87
  } as const;
76
88
  }
77
89
 
78
- export function summarizePatchCounts(patch: string): {
90
+ function countDiffLines(value: string): number {
91
+ if (value.length === 0) return 0;
92
+ const lines = value.split('\n');
93
+ if (value.endsWith('\n')) lines.pop();
94
+ return lines.length;
95
+ }
96
+
97
+ export function summarizeTextChanges(
98
+ oldText: string,
99
+ newText: string,
100
+ ): {
79
101
  additions: number;
80
102
  deletions: number;
81
103
  } {
82
- let adds = 0;
83
- let dels = 0;
84
- for (const line of String(patch || '').split('\n')) {
85
- if (
86
- line.startsWith('+++') ||
87
- line.startsWith('---') ||
88
- line.startsWith('diff ')
89
- )
90
- continue;
91
- if (line.startsWith('+')) adds += 1;
92
- else if (line.startsWith('-')) dels += 1;
104
+ let additions = 0;
105
+ let deletions = 0;
106
+ for (const part of diffLines(String(oldText ?? ''), String(newText ?? ''))) {
107
+ const lineCount = countDiffLines(part.value);
108
+ if (part.added) additions += lineCount;
109
+ else if (part.removed) deletions += lineCount;
93
110
  }
94
- return { additions: adds, deletions: dels };
111
+ return { additions, deletions };
95
112
  }
@@ -3,6 +3,7 @@ import { z } from 'zod/v3';
3
3
  import { readFile, writeFile, mkdir } from 'node:fs/promises';
4
4
  import { dirname } from 'node:path';
5
5
  import {
6
+ buildMutationMetadata,
6
7
  buildWriteArtifact,
7
8
  resolveSafePath,
8
9
  expandTilde,
@@ -38,7 +39,13 @@ export function buildWriteTool(projectRoot: string): {
38
39
  }): Promise<
39
40
  ToolResponse<{
40
41
  path: string;
42
+ operation: 'write';
41
43
  bytes: number;
44
+ bytesWritten: number;
45
+ created: boolean;
46
+ changed: boolean;
47
+ sha256: string;
48
+ summary: { files: number; additions: number; deletions: number };
42
49
  artifact: unknown;
43
50
  }>
44
51
  > {
@@ -81,6 +88,7 @@ export function buildWriteTool(projectRoot: string): {
81
88
  } catch {}
82
89
  await writeFile(abs, content);
83
90
  await rememberFileWrite(projectRoot, abs);
91
+ const metadata = buildMutationMetadata(oldText, content);
84
92
  const artifact = await buildWriteArtifact(
85
93
  req,
86
94
  existed,
@@ -90,7 +98,13 @@ export function buildWriteTool(projectRoot: string): {
90
98
  return {
91
99
  ok: true,
92
100
  path: req,
93
- bytes: content.length,
101
+ operation: 'write',
102
+ bytes: metadata.bytesWritten,
103
+ bytesWritten: metadata.bytesWritten,
104
+ created: !existed,
105
+ changed: metadata.changed,
106
+ sha256: metadata.sha256,
107
+ summary: metadata.summary,
94
108
  artifact,
95
109
  };
96
110
  } catch (error: unknown) {
@@ -8,6 +8,9 @@ import type {
8
8
  PatchAddOperation,
9
9
  PatchApplicationResult,
10
10
  PatchDeleteOperation,
11
+ PatchLineDeleteOperation,
12
+ PatchLineInsertOperation,
13
+ PatchLineReplaceOperation,
11
14
  PatchOperation,
12
15
  PatchUpdateOperation,
13
16
  RejectedPatch,
@@ -161,6 +164,174 @@ async function applyUpdateOperation(
161
164
  return makeAppliedRecord('update', operation.filePath, appliedHunks);
162
165
  }
163
166
 
167
+ function resolveLineRange(
168
+ filePath: string,
169
+ lineCount: number,
170
+ startLine: number,
171
+ endLine: number | 'end',
172
+ ) {
173
+ const resolvedEndLine = endLine === 'end' ? lineCount : endLine;
174
+ if (startLine > lineCount) {
175
+ throw new Error(
176
+ `Line range ${startLine}-${resolvedEndLine} is outside ${filePath} (${lineCount} lines).`,
177
+ );
178
+ }
179
+ if (resolvedEndLine > lineCount) {
180
+ throw new Error(
181
+ `Line range ${startLine}-${resolvedEndLine} is outside ${filePath} (${lineCount} lines).`,
182
+ );
183
+ }
184
+ if (resolvedEndLine < startLine) {
185
+ throw new Error('Line range end must be greater than or equal to start.');
186
+ }
187
+ return {
188
+ startIndex: startLine - 1,
189
+ endIndexExclusive: resolvedEndLine,
190
+ resolvedEndLine,
191
+ };
192
+ }
193
+
194
+ async function readUpdateTarget(projectRoot: string, filePath: string) {
195
+ const target = resolveProjectPath(projectRoot, filePath);
196
+ let original: string;
197
+ try {
198
+ original = await readFile(target, 'utf-8');
199
+ } catch (error) {
200
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
201
+ throw new Error(`File not found: ${filePath}`);
202
+ }
203
+ throw error;
204
+ }
205
+ return { target, ...splitLines(original) };
206
+ }
207
+
208
+ async function applyLineDeleteOperation(
209
+ projectRoot: string,
210
+ operation: PatchLineDeleteOperation,
211
+ ): Promise<AppliedPatchOperation> {
212
+ const { target, lines, newline } = await readUpdateTarget(
213
+ projectRoot,
214
+ operation.filePath,
215
+ );
216
+ const { startIndex, endIndexExclusive, resolvedEndLine } = resolveLineRange(
217
+ operation.filePath,
218
+ lines.length,
219
+ operation.startLine,
220
+ operation.endLine,
221
+ );
222
+ const removed = lines.slice(startIndex, endIndexExclusive);
223
+ const workingLines = [...lines];
224
+ workingLines.splice(startIndex, removed.length);
225
+ ensureTrailingNewline(workingLines);
226
+ await writeFile(target, joinLines(workingLines, newline), 'utf-8');
227
+
228
+ const appliedHunk: AppliedPatchHunk = {
229
+ header: {
230
+ oldStart: operation.startLine,
231
+ oldLines: removed.length,
232
+ newStart: operation.startLine,
233
+ newLines: 0,
234
+ context: `lines ${operation.startLine}-${resolvedEndLine}`,
235
+ },
236
+ lines: removed.map((line) => ({ kind: 'remove', content: line })),
237
+ oldStart: operation.startLine,
238
+ oldLines: removed.length,
239
+ newStart: operation.startLine,
240
+ newLines: 0,
241
+ additions: 0,
242
+ deletions: removed.length,
243
+ };
244
+
245
+ return makeAppliedRecord('update', operation.filePath, [appliedHunk]);
246
+ }
247
+
248
+ async function applyLineReplaceOperation(
249
+ projectRoot: string,
250
+ operation: PatchLineReplaceOperation,
251
+ ): Promise<AppliedPatchOperation> {
252
+ const { target, lines, newline } = await readUpdateTarget(
253
+ projectRoot,
254
+ operation.filePath,
255
+ );
256
+ const { startIndex, endIndexExclusive, resolvedEndLine } = resolveLineRange(
257
+ operation.filePath,
258
+ lines.length,
259
+ operation.startLine,
260
+ operation.endLine,
261
+ );
262
+ const removed = lines.slice(startIndex, endIndexExclusive);
263
+ const added = [...operation.lines];
264
+ const workingLines = [...lines];
265
+ workingLines.splice(startIndex, removed.length, ...added);
266
+ ensureTrailingNewline(workingLines);
267
+ await writeFile(target, joinLines(workingLines, newline), 'utf-8');
268
+
269
+ const appliedHunk: AppliedPatchHunk = {
270
+ header: {
271
+ oldStart: operation.startLine,
272
+ oldLines: removed.length,
273
+ newStart: operation.startLine,
274
+ newLines: added.length,
275
+ context: `lines ${operation.startLine}-${resolvedEndLine}`,
276
+ },
277
+ lines: [
278
+ ...removed.map((line) => ({ kind: 'remove' as const, content: line })),
279
+ ...added.map((line) => ({ kind: 'add' as const, content: line })),
280
+ ],
281
+ oldStart: operation.startLine,
282
+ oldLines: removed.length,
283
+ newStart: operation.startLine,
284
+ newLines: added.length,
285
+ additions: added.length,
286
+ deletions: removed.length,
287
+ };
288
+
289
+ return makeAppliedRecord('update', operation.filePath, [appliedHunk]);
290
+ }
291
+
292
+ async function applyLineInsertOperation(
293
+ projectRoot: string,
294
+ operation: PatchLineInsertOperation,
295
+ ): Promise<AppliedPatchOperation> {
296
+ const { target, lines, newline } = await readUpdateTarget(
297
+ projectRoot,
298
+ operation.filePath,
299
+ );
300
+ const insertIndex =
301
+ operation.position === 'before' ? operation.line - 1 : operation.line;
302
+ if (insertIndex < 0 || insertIndex > lines.length) {
303
+ throw new Error(
304
+ `Insert ${operation.position} line ${operation.line} is outside ${operation.filePath} (${lines.length} lines).`,
305
+ );
306
+ }
307
+ const added = [...operation.lines];
308
+ const workingLines = [...lines];
309
+ workingLines.splice(insertIndex, 0, ...added);
310
+ ensureTrailingNewline(workingLines);
311
+ await writeFile(target, joinLines(workingLines, newline), 'utf-8');
312
+
313
+ const oldStart = insertIndex;
314
+ const newStart = insertIndex + 1;
315
+ const appliedHunk: AppliedPatchHunk = {
316
+ header: {
317
+ oldStart,
318
+ oldLines: 0,
319
+ newStart,
320
+ newLines: added.length,
321
+ context: `${operation.position} line ${operation.line}`,
322
+ },
323
+ lines: added.map((line) => ({ kind: 'add', content: line })),
324
+ oldStart,
325
+ oldLines: 0,
326
+ newStart,
327
+ newLines: added.length,
328
+ additions: added.length,
329
+ deletions: 0,
330
+ };
331
+
332
+ return makeAppliedRecord('update', operation.filePath, [appliedHunk]);
333
+ }
334
+
164
335
  export async function applyPatchOperations(
165
336
  projectRoot: string,
166
337
  operations: PatchOperation[],
@@ -175,6 +346,12 @@ export async function applyPatchOperations(
175
346
  applied.push(await applyAddOperation(projectRoot, operation));
176
347
  } else if (operation.kind === 'delete') {
177
348
  applied.push(await applyDeleteOperation(projectRoot, operation));
349
+ } else if (operation.kind === 'line-delete') {
350
+ applied.push(await applyLineDeleteOperation(projectRoot, operation));
351
+ } else if (operation.kind === 'line-replace') {
352
+ applied.push(await applyLineReplaceOperation(projectRoot, operation));
353
+ } else if (operation.kind === 'line-insert') {
354
+ applied.push(await applyLineInsertOperation(projectRoot, operation));
178
355
  } else {
179
356
  applied.push(
180
357
  await applyUpdateOperation(
@@ -4,5 +4,11 @@ export const PATCH_ADD_PREFIX = '*** Add File:';
4
4
  export const PATCH_UPDATE_PREFIX = '*** Update File:';
5
5
  export const PATCH_DELETE_PREFIX = '*** Delete File:';
6
6
  export const PATCH_REPLACE_PREFIX = '*** Replace in:';
7
+ export const PATCH_DELETE_LINES_PREFIX = '*** Delete Lines in:';
8
+ export const PATCH_REPLACE_LINES_PREFIX = '*** Replace Lines in:';
9
+ export const PATCH_INSERT_BEFORE_PREFIX = '*** Insert Before in:';
10
+ export const PATCH_INSERT_AFTER_PREFIX = '*** Insert After in:';
7
11
  export const PATCH_FIND_MARKER = '*** Find:';
8
12
  export const PATCH_WITH_MARKER = '*** With:';
13
+ export const PATCH_LINES_MARKER = '*** Lines:';
14
+ export const PATCH_LINE_MARKER = '*** Line:';
@@ -2,8 +2,14 @@ import {
2
2
  PATCH_ADD_PREFIX,
3
3
  PATCH_BEGIN_MARKER,
4
4
  PATCH_DELETE_PREFIX,
5
+ PATCH_DELETE_LINES_PREFIX,
5
6
  PATCH_END_MARKER,
6
7
  PATCH_FIND_MARKER,
8
+ PATCH_INSERT_AFTER_PREFIX,
9
+ PATCH_INSERT_BEFORE_PREFIX,
10
+ PATCH_LINE_MARKER,
11
+ PATCH_LINES_MARKER,
12
+ PATCH_REPLACE_LINES_PREFIX,
7
13
  PATCH_REPLACE_PREFIX,
8
14
  PATCH_UPDATE_PREFIX,
9
15
  PATCH_WITH_MARKER,
@@ -20,6 +26,9 @@ import type {
20
26
  PatchDeleteOperation,
21
27
  PatchHunk,
22
28
  PatchHunkLine,
29
+ PatchLineDeleteOperation,
30
+ PatchLineInsertOperation,
31
+ PatchLineReplaceOperation,
23
32
  PatchOperation,
24
33
  PatchUpdateOperation,
25
34
  } from './types.ts';
@@ -35,6 +44,62 @@ function parseDirectivePath(line: string, prefix: string): string {
35
44
  return filePath;
36
45
  }
37
46
 
47
+ function parsePositiveLineNumber(value: string, label: string): number {
48
+ const trimmed = value.trim();
49
+ if (!/^\d+$/.test(trimmed)) {
50
+ throw new Error(`${label} must be a positive integer.`);
51
+ }
52
+ const line = Number.parseInt(trimmed, 10);
53
+ if (line < 1) {
54
+ throw new Error(`${label} must be a positive integer.`);
55
+ }
56
+ return line;
57
+ }
58
+
59
+ function parseLineRange(value: string): {
60
+ startLine: number;
61
+ endLine: number | 'end';
62
+ } {
63
+ const trimmed = value.trim();
64
+ const match = /^(\d+)(?:\s*-\s*(\d+|end|eof|\$))?$/i.exec(trimmed);
65
+ if (!match) {
66
+ throw new Error(
67
+ 'Line ranges must use "start" or "start-end" with 1-indexed positive integers.',
68
+ );
69
+ }
70
+
71
+ const startLine = parsePositiveLineNumber(match[1], 'Line range start');
72
+ const endLineToken = match[2];
73
+ if (!endLineToken) return { startLine, endLine: startLine };
74
+ const endLine = /^(end|eof|\$)$/i.test(endLineToken)
75
+ ? 'end'
76
+ : parsePositiveLineNumber(endLineToken, 'Line range end');
77
+ if (typeof endLine === 'number' && endLine < startLine) {
78
+ throw new Error('Line range end must be greater than or equal to start.');
79
+ }
80
+ return { startLine, endLine };
81
+ }
82
+
83
+ type LineDeleteBuilder = Partial<PatchLineDeleteOperation> & {
84
+ kind: 'line-delete';
85
+ filePath: string;
86
+ };
87
+
88
+ type LineReplaceBuilder = Partial<PatchLineReplaceOperation> & {
89
+ kind: 'line-replace';
90
+ filePath: string;
91
+ lines: string[];
92
+ phase: 'range' | 'with';
93
+ };
94
+
95
+ type LineInsertBuilder = Partial<PatchLineInsertOperation> & {
96
+ kind: 'line-insert';
97
+ filePath: string;
98
+ position: 'before' | 'after';
99
+ lines: string[];
100
+ phase: 'line' | 'with';
101
+ };
102
+
38
103
  export function parseEnvelopedPatch(patch: string): PatchOperation[] {
39
104
  const normalized = patch.replace(/\r\n/g, '\n');
40
105
  const lines = normalized.split('\n');
@@ -47,6 +112,9 @@ export function parseEnvelopedPatch(patch: string): PatchOperation[] {
47
112
  kind: 'update';
48
113
  currentHunk: PatchHunk | null;
49
114
  })
115
+ | LineDeleteBuilder
116
+ | LineReplaceBuilder
117
+ | LineInsertBuilder
50
118
  | ReplaceBuilder;
51
119
 
52
120
  let builder: Builder | null = null;
@@ -80,6 +148,54 @@ export function parseEnvelopedPatch(patch: string): PatchOperation[] {
80
148
  filePath: builder.filePath,
81
149
  lines: [...builder.lines],
82
150
  });
151
+ } else if (builder.kind === 'line-delete') {
152
+ if (!builder.startLine || !builder.endLine) {
153
+ throw new Error(
154
+ `Delete Lines in ${builder.filePath}: missing required *** Lines: directive.`,
155
+ );
156
+ }
157
+ operations.push({
158
+ kind: 'line-delete',
159
+ filePath: builder.filePath,
160
+ startLine: builder.startLine,
161
+ endLine: builder.endLine,
162
+ });
163
+ } else if (builder.kind === 'line-replace') {
164
+ if (!builder.startLine || !builder.endLine) {
165
+ throw new Error(
166
+ `Replace Lines in ${builder.filePath}: missing required *** Lines: directive.`,
167
+ );
168
+ }
169
+ if (builder.phase !== 'with') {
170
+ throw new Error(
171
+ `Replace Lines in ${builder.filePath}: missing required *** With: directive.`,
172
+ );
173
+ }
174
+ operations.push({
175
+ kind: 'line-replace',
176
+ filePath: builder.filePath,
177
+ startLine: builder.startLine,
178
+ endLine: builder.endLine,
179
+ lines: [...builder.lines],
180
+ });
181
+ } else if (builder.kind === 'line-insert') {
182
+ if (!builder.line) {
183
+ throw new Error(
184
+ `Insert ${builder.position} in ${builder.filePath}: missing required *** Line: directive.`,
185
+ );
186
+ }
187
+ if (builder.phase !== 'with') {
188
+ throw new Error(
189
+ `Insert ${builder.position} in ${builder.filePath}: missing required *** With: directive.`,
190
+ );
191
+ }
192
+ operations.push({
193
+ kind: 'line-insert',
194
+ filePath: builder.filePath,
195
+ position: builder.position,
196
+ line: builder.line,
197
+ lines: [...builder.lines],
198
+ });
83
199
  } else {
84
200
  operations.push({ kind: 'delete', filePath: builder.filePath });
85
201
  }
@@ -154,6 +270,50 @@ export function parseEnvelopedPatch(patch: string): PatchOperation[] {
154
270
  continue;
155
271
  }
156
272
 
273
+ if (line.startsWith(PATCH_DELETE_LINES_PREFIX)) {
274
+ flushBuilder();
275
+ builder = {
276
+ kind: 'line-delete',
277
+ filePath: parseDirectivePath(line, PATCH_DELETE_LINES_PREFIX),
278
+ };
279
+ continue;
280
+ }
281
+
282
+ if (line.startsWith(PATCH_REPLACE_LINES_PREFIX)) {
283
+ flushBuilder();
284
+ builder = {
285
+ kind: 'line-replace',
286
+ filePath: parseDirectivePath(line, PATCH_REPLACE_LINES_PREFIX),
287
+ lines: [],
288
+ phase: 'range',
289
+ };
290
+ continue;
291
+ }
292
+
293
+ if (line.startsWith(PATCH_INSERT_BEFORE_PREFIX)) {
294
+ flushBuilder();
295
+ builder = {
296
+ kind: 'line-insert',
297
+ filePath: parseDirectivePath(line, PATCH_INSERT_BEFORE_PREFIX),
298
+ position: 'before',
299
+ lines: [],
300
+ phase: 'line',
301
+ };
302
+ continue;
303
+ }
304
+
305
+ if (line.startsWith(PATCH_INSERT_AFTER_PREFIX)) {
306
+ flushBuilder();
307
+ builder = {
308
+ kind: 'line-insert',
309
+ filePath: parseDirectivePath(line, PATCH_INSERT_AFTER_PREFIX),
310
+ position: 'after',
311
+ lines: [],
312
+ phase: 'line',
313
+ };
314
+ continue;
315
+ }
316
+
157
317
  if (builder && builder.kind === 'replace') {
158
318
  if (line.startsWith(PATCH_FIND_MARKER)) {
159
319
  flushReplacePair(builder);
@@ -188,6 +348,74 @@ export function parseEnvelopedPatch(patch: string): PatchOperation[] {
188
348
  throw new Error(`Unexpected content in patch: "${line}"`);
189
349
  }
190
350
 
351
+ if (builder.kind === 'line-delete') {
352
+ if (line.startsWith(PATCH_LINES_MARKER)) {
353
+ const range = parseLineRange(line.slice(PATCH_LINES_MARKER.length));
354
+ builder.startLine = range.startLine;
355
+ builder.endLine = range.endLine;
356
+ continue;
357
+ }
358
+ if (line.trim() !== '') {
359
+ throw new Error(
360
+ `Delete Lines in ${builder.filePath}: expected *** Lines: directive, got "${line}"`,
361
+ );
362
+ }
363
+ continue;
364
+ }
365
+
366
+ if (builder.kind === 'line-replace') {
367
+ if (builder.phase === 'with') {
368
+ builder.lines.push(line);
369
+ continue;
370
+ }
371
+ if (line.startsWith(PATCH_LINES_MARKER)) {
372
+ const range = parseLineRange(line.slice(PATCH_LINES_MARKER.length));
373
+ builder.startLine = range.startLine;
374
+ builder.endLine = range.endLine;
375
+ continue;
376
+ }
377
+ if (line.startsWith(PATCH_WITH_MARKER)) {
378
+ if (!builder.startLine || !builder.endLine) {
379
+ throw new Error(
380
+ `Replace Lines in ${builder.filePath}: *** With: must follow *** Lines:.`,
381
+ );
382
+ }
383
+ builder.phase = 'with';
384
+ continue;
385
+ }
386
+ if (line.trim() === '') continue;
387
+ throw new Error(
388
+ `Replace Lines in ${builder.filePath}: expected *** Lines: or *** With: directive, got "${line}"`,
389
+ );
390
+ }
391
+
392
+ if (builder.kind === 'line-insert') {
393
+ if (builder.phase === 'with') {
394
+ builder.lines.push(line);
395
+ continue;
396
+ }
397
+ if (line.startsWith(PATCH_LINE_MARKER)) {
398
+ builder.line = parsePositiveLineNumber(
399
+ line.slice(PATCH_LINE_MARKER.length),
400
+ 'Insert line',
401
+ );
402
+ continue;
403
+ }
404
+ if (line.startsWith(PATCH_WITH_MARKER)) {
405
+ if (!builder.line) {
406
+ throw new Error(
407
+ `Insert ${builder.position} in ${builder.filePath}: *** With: must follow *** Line:.`,
408
+ );
409
+ }
410
+ builder.phase = 'with';
411
+ continue;
412
+ }
413
+ if (line.trim() === '') continue;
414
+ throw new Error(
415
+ `Insert ${builder.position} in ${builder.filePath}: expected *** Line: or *** With: directive, got "${line}"`,
416
+ );
417
+ }
418
+
191
419
  if (builder.kind === 'add') {
192
420
  builder.lines.push(line.startsWith('+') ? line.slice(1) : line);
193
421
  continue;
@@ -1,4 +1,10 @@
1
- export type PatchOperationKind = 'add' | 'update' | 'delete';
1
+ export type AppliedPatchOperationKind = 'add' | 'update' | 'delete';
2
+
3
+ export type PatchOperationKind =
4
+ | AppliedPatchOperationKind
5
+ | 'line-delete'
6
+ | 'line-replace'
7
+ | 'line-insert';
2
8
 
3
9
  export interface PatchHunkLine {
4
10
  kind: 'context' | 'add' | 'remove';
@@ -35,10 +41,36 @@ export interface PatchUpdateOperation {
35
41
  hunks: PatchHunk[];
36
42
  }
37
43
 
44
+ export interface PatchLineDeleteOperation {
45
+ kind: 'line-delete';
46
+ filePath: string;
47
+ startLine: number;
48
+ endLine: number | 'end';
49
+ }
50
+
51
+ export interface PatchLineReplaceOperation {
52
+ kind: 'line-replace';
53
+ filePath: string;
54
+ startLine: number;
55
+ endLine: number | 'end';
56
+ lines: string[];
57
+ }
58
+
59
+ export interface PatchLineInsertOperation {
60
+ kind: 'line-insert';
61
+ filePath: string;
62
+ position: 'before' | 'after';
63
+ line: number;
64
+ lines: string[];
65
+ }
66
+
38
67
  export type PatchOperation =
39
68
  | PatchAddOperation
40
69
  | PatchDeleteOperation
41
- | PatchUpdateOperation;
70
+ | PatchUpdateOperation
71
+ | PatchLineDeleteOperation
72
+ | PatchLineReplaceOperation
73
+ | PatchLineInsertOperation;
42
74
 
43
75
  export interface AppliedPatchHunk {
44
76
  header: PatchHunkHeader;
@@ -52,7 +84,7 @@ export interface AppliedPatchHunk {
52
84
  }
53
85
 
54
86
  export interface AppliedPatchOperation {
55
- kind: PatchOperationKind;
87
+ kind: AppliedPatchOperationKind;
56
88
  filePath: string;
57
89
  stats: {
58
90
  additions: number;
@@ -41,10 +41,6 @@ function serializeRejected(rejected: RejectedPatch[]) {
41
41
  newStart: hunk.header.newStart,
42
42
  newLines: hunk.header.newLines,
43
43
  context: hunk.header.context,
44
- lines: hunk.lines.map((line) => ({
45
- kind: line.kind,
46
- content: line.content,
47
- })),
48
44
  }))
49
45
  : undefined,
50
46
  }));
@@ -83,7 +79,10 @@ export function buildApplyPatchTool(projectRoot: string): {
83
79
  fuzzyMatch?: boolean;
84
80
  }): Promise<
85
81
  ToolResponse<{
82
+ operation: 'apply_patch';
86
83
  output: string;
84
+ changed: boolean;
85
+ summary: { files: number; additions: number; deletions: number };
87
86
  changes: unknown[];
88
87
  artifact: unknown;
89
88
  rejected?: unknown[];
@@ -142,7 +141,10 @@ export function buildApplyPatchTool(projectRoot: string): {
142
141
 
143
142
  return {
144
143
  ok: true,
144
+ operation: 'apply_patch',
145
145
  output: output.join('; '),
146
+ changed: result.operations.length > 0,
147
+ summary: result.summary,
146
148
  changes,
147
149
  artifact: {
148
150
  kind: 'file_diff',
@@ -20,6 +20,46 @@ You can include multiple `*** Find:` / `*** With:` pairs under one `*** Replace
20
20
 
21
21
  ---
22
22
 
23
+ ## Line-number mode: Delete / Replace / Insert
24
+
25
+ Use line-number directives for large removals or replacements after reading the
26
+ target file. Lines are 1-indexed and ranges are inclusive. `end`, `eof`, and `$`
27
+ can be used as a range end.
28
+
29
+ ```text
30
+ *** Begin Patch
31
+ *** Delete Lines in: path/to/file.ts
32
+ *** Lines: 120-184
33
+ *** End Patch
34
+ ```
35
+
36
+ ```text
37
+ *** Begin Patch
38
+ *** Replace Lines in: path/to/file.ts
39
+ *** Lines: 120-184
40
+ *** With:
41
+ new replacement block
42
+ *** End Patch
43
+ ```
44
+
45
+ ```text
46
+ *** Begin Patch
47
+ *** Insert Before in: path/to/file.ts
48
+ *** Line: 120
49
+ *** With:
50
+ new block before line 120
51
+ *** Insert After in: path/to/file.ts
52
+ *** Line: 200
53
+ *** With:
54
+ new block after line 200
55
+ *** End Patch
56
+ ```
57
+
58
+ Line-number mode is concise but fragile if the file changes after you read it.
59
+ Prefer text/context patches for small edits.
60
+
61
+ ---
62
+
23
63
  ## Standard mode: Add / Update / Delete
24
64
 
25
65
  ```text
@@ -48,9 +88,9 @@ For multiple edits in the same file, use **one** `*** Update File:` block with m
48
88
  - File uses tabs → patch uses tabs.
49
89
  - File uses 2 spaces → patch uses 2 spaces (not 4, not tabs).
50
90
 
51
- **4. Include Sufficient Context.** Minimum 2 context lines before AND after each change (3+ for YAML). A single context line is fragile — it may match multiple locations.
91
+ **4. Include Sufficient Context.** Minimum 2 context lines before AND after each change (3+ for YAML). A single context line is fragile — it may match multiple locations. For line-number mode, verify the exact line numbers from the latest `read` output instead.
52
92
 
53
- **5. Markers Required.** Every patch MUST start with `*** Begin Patch` and end with `*** End Patch`. Use `*** Update File:`, `*** Add File:`, or `*** Delete File:`.
93
+ **5. Markers Required.** Every patch MUST start with `*** Begin Patch` and end with `*** End Patch`. Use `*** Update File:`, `*** Add File:`, `*** Delete File:`, or a line-number directive.
54
94
 
55
95
  **6. One `apply_patch` Call Per File.** For multiple edits in the same file, use multiple `@@` hunks in ONE call. Never make separate `apply_patch` calls for the same file in one turn.
56
96
 
@@ -14,12 +14,18 @@ const CODEX_RESPONSES_URL = 'https://chatgpt.com/backend-api/codex/responses';
14
14
  const TOKEN_EXPIRY_BUFFER_MS = 5 * 60 * 1000;
15
15
  const TOKEN_REFRESH_MAX_RETRIES = 2;
16
16
  const TOKEN_REFRESH_RETRY_DELAY_MS = 1000;
17
+ const CODEX_INSTALLATION_ID = crypto.randomUUID();
18
+ const CODEX_REQUEST_TIMEOUT_MS = 120_000;
19
+ const CODEX_STREAM_IDLE_TIMEOUT_MS = 120_000;
17
20
 
18
21
  type OpenAIOAuthSessionState = {
19
22
  responseId?: string;
20
23
  model?: string;
21
24
  status?: string;
22
25
  incompleteReason?: string;
26
+ turnState?: string;
27
+ installationId?: string;
28
+ windowId?: string;
23
29
  };
24
30
 
25
31
  const openAIOAuthSessionState = new Map<string, OpenAIOAuthSessionState>();
@@ -83,6 +89,27 @@ function shouldUsePreviousResponseId() {
83
89
  return process.env.OTTO_OPENAI_OAUTH_PREVIOUS_RESPONSE_ID === '1';
84
90
  }
85
91
 
92
+ function parsePositiveIntegerEnv(name: string, fallback: number) {
93
+ const raw = process.env[name];
94
+ if (!raw) return fallback;
95
+ const parsed = Number.parseInt(raw, 10);
96
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
97
+ }
98
+
99
+ function getCodexRequestTimeoutMs() {
100
+ return parsePositiveIntegerEnv(
101
+ 'OTTO_OPENAI_OAUTH_REQUEST_TIMEOUT_MS',
102
+ CODEX_REQUEST_TIMEOUT_MS,
103
+ );
104
+ }
105
+
106
+ function getCodexStreamIdleTimeoutMs() {
107
+ return parsePositiveIntegerEnv(
108
+ 'OTTO_OPENAI_OAUTH_STREAM_IDLE_TIMEOUT_MS',
109
+ CODEX_STREAM_IDLE_TIMEOUT_MS,
110
+ );
111
+ }
112
+
86
113
  export function clearOpenAIOAuthSessionState(sessionId?: string) {
87
114
  if (sessionId) {
88
115
  openAIOAuthSessionState.delete(sessionId);
@@ -171,6 +198,17 @@ function writeSessionState(sessionId: string, next: OpenAIOAuthSessionState) {
171
198
  openAIOAuthSessionState.set(sessionId, next);
172
199
  }
173
200
 
201
+ function mergeSessionState(sessionId: string, next: OpenAIOAuthSessionState) {
202
+ writeSessionState(sessionId, {
203
+ ...readSessionState(sessionId),
204
+ ...next,
205
+ });
206
+ }
207
+
208
+ function getCodexWindowId(sessionId: string) {
209
+ return `${sessionId}:0`;
210
+ }
211
+
174
212
  function rewriteRequestBody(
175
213
  body: string,
176
214
  sessionId?: string,
@@ -178,8 +216,25 @@ function rewriteRequestBody(
178
216
  try {
179
217
  const parsed = JSON.parse(body) as Record<string, unknown>;
180
218
  const model = typeof parsed.model === 'string' ? parsed.model : undefined;
219
+ let changed = stripStatelessResponseInputIds(parsed);
181
220
  if (!sessionId) {
182
- return { body, model };
221
+ return { body: changed ? JSON.stringify(parsed) : body, model };
222
+ }
223
+
224
+ const clientMetadata =
225
+ parsed.client_metadata && typeof parsed.client_metadata === 'object'
226
+ ? (parsed.client_metadata as Record<string, unknown>)
227
+ : {};
228
+ if (clientMetadata['x-codex-installation-id'] !== CODEX_INSTALLATION_ID) {
229
+ parsed.client_metadata = {
230
+ ...clientMetadata,
231
+ 'x-codex-installation-id': CODEX_INSTALLATION_ID,
232
+ };
233
+ changed = true;
234
+ }
235
+ if (typeof parsed.prompt_cache_key !== 'string') {
236
+ parsed.prompt_cache_key = sessionId;
237
+ changed = true;
183
238
  }
184
239
 
185
240
  const prior = readSessionState(sessionId);
@@ -192,9 +247,10 @@ function rewriteRequestBody(
192
247
  logOpenAIOAuth(
193
248
  `not injecting previous_response_id=${prior.responseId} for session=${sessionId} model=${model ?? 'unknown'} because Codex HTTP backend rejects it; enable OTTO_OPENAI_OAUTH_PREVIOUS_RESPONSE_ID=1 only for validation`,
194
249
  );
195
- return { body, model };
250
+ return { body: changed ? JSON.stringify(parsed) : body, model };
196
251
  }
197
252
  parsed.previous_response_id = prior.responseId;
253
+ changed = true;
198
254
  logOpenAIOAuth(
199
255
  `injecting previous_response_id=${prior.responseId} for session=${sessionId} model=${model ?? 'unknown'}`,
200
256
  );
@@ -205,12 +261,41 @@ function rewriteRequestBody(
205
261
  };
206
262
  }
207
263
 
208
- return { body, model };
264
+ return { body: changed ? JSON.stringify(parsed) : body, model };
209
265
  } catch {
210
266
  return { body };
211
267
  }
212
268
  }
213
269
 
270
+ function stripStatelessResponseInputIds(
271
+ parsed: Record<string, unknown>,
272
+ ): boolean {
273
+ if (parsed.store === true || !Array.isArray(parsed.input)) {
274
+ return false;
275
+ }
276
+
277
+ let changed = false;
278
+ const input = parsed.input.map((item) => {
279
+ if (!item || typeof item !== 'object' || Array.isArray(item)) {
280
+ return item;
281
+ }
282
+ if (!('id' in item)) {
283
+ return item;
284
+ }
285
+
286
+ const next = { ...(item as Record<string, unknown>) };
287
+ delete next.id;
288
+ changed = true;
289
+ return next;
290
+ });
291
+
292
+ if (changed) {
293
+ parsed.input = input;
294
+ }
295
+
296
+ return changed;
297
+ }
298
+
214
299
  function previewText(value: unknown, maxLength = 240): string | undefined {
215
300
  if (typeof value !== 'string') return undefined;
216
301
  const normalized = value.replace(/\s+/g, ' ').trim();
@@ -283,6 +368,9 @@ function trackResponseEvent(data: string, sessionId?: string) {
283
368
  model: responseModel ?? prior?.model,
284
369
  status: responseStatus ?? type,
285
370
  incompleteReason,
371
+ turnState: prior?.turnState,
372
+ installationId: prior?.installationId,
373
+ windowId: prior?.windowId,
286
374
  });
287
375
  logOpenAIOAuth(
288
376
  `tracked response event type=${type ?? 'unknown'} responseId=${responseId} session=${sessionId} status=${responseStatus ?? 'unknown'} incompleteReason=${incompleteReason ?? 'none'}`,
@@ -304,9 +392,29 @@ function trackResponsesStream(
304
392
  const decoder = new TextDecoder();
305
393
  const encoder = new TextEncoder();
306
394
  let buffer = '';
395
+ let timeout: Timer | undefined;
396
+ const idleTimeoutMs = getCodexStreamIdleTimeoutMs();
397
+ const clearIdleTimeout = () => {
398
+ if (timeout) clearTimeout(timeout);
399
+ timeout = undefined;
400
+ };
401
+ const resetIdleTimeout = (controller: TransformStreamDefaultController) => {
402
+ clearIdleTimeout();
403
+ timeout = setTimeout(() => {
404
+ controller.error(
405
+ new Error(
406
+ `OpenAI OAuth Codex stream idle timeout after ${idleTimeoutMs}ms`,
407
+ ),
408
+ );
409
+ }, idleTimeoutMs);
410
+ };
307
411
 
308
412
  const transform = new TransformStream<Uint8Array, Uint8Array>({
413
+ start(controller) {
414
+ resetIdleTimeout(controller);
415
+ },
309
416
  transform(chunk, controller) {
417
+ resetIdleTimeout(controller);
310
418
  buffer += decoder.decode(chunk, { stream: true }).replace(/\r\n/g, '\n');
311
419
  let boundary = buffer.indexOf('\n\n');
312
420
  while (boundary !== -1) {
@@ -329,6 +437,7 @@ function trackResponsesStream(
329
437
  }
330
438
  },
331
439
  flush(controller) {
440
+ clearIdleTimeout();
332
441
  buffer += decoder.decode().replace(/\r\n/g, '\n');
333
442
  if (buffer.length > 0) {
334
443
  controller.enqueue(encoder.encode(buffer));
@@ -343,6 +452,72 @@ function trackResponsesStream(
343
452
  });
344
453
  }
345
454
 
455
+ async function fetchWithCodexRequestTimeout(
456
+ url: string,
457
+ init: RequestInit,
458
+ args: {
459
+ enabled: boolean;
460
+ sessionId?: string;
461
+ model?: string;
462
+ requestStartedAt: number;
463
+ },
464
+ ) {
465
+ if (!args.enabled) {
466
+ return fetch(url, {
467
+ ...init,
468
+ // @ts-expect-error Bun-specific fetch option
469
+ timeout: false,
470
+ });
471
+ }
472
+
473
+ const timeoutMs = getCodexRequestTimeoutMs();
474
+ const controller = new AbortController();
475
+ const timeout = setTimeout(() => {
476
+ controller.abort(
477
+ new Error(
478
+ `OpenAI OAuth Codex request timeout before response after ${timeoutMs}ms`,
479
+ ),
480
+ );
481
+ }, timeoutMs);
482
+ let abortedByParent = false;
483
+ const parentSignal = init.signal;
484
+ const abortFromParent = () => {
485
+ abortedByParent = true;
486
+ controller.abort(parentSignal?.reason);
487
+ };
488
+ if (parentSignal) {
489
+ if (parentSignal.aborted) {
490
+ abortFromParent();
491
+ } else {
492
+ parentSignal.addEventListener('abort', abortFromParent, { once: true });
493
+ }
494
+ }
495
+
496
+ try {
497
+ return await fetch(url, {
498
+ ...init,
499
+ signal: controller.signal,
500
+ // @ts-expect-error Bun-specific fetch option
501
+ timeout: false,
502
+ });
503
+ } catch (error) {
504
+ if (!abortedByParent && controller.signal.aborted) {
505
+ loggerWarn('[openai-oauth] request timed out before response', {
506
+ sessionId: args.sessionId,
507
+ model: args.model,
508
+ timeoutMs,
509
+ durationMs: Date.now() - args.requestStartedAt,
510
+ });
511
+ }
512
+ throw error;
513
+ } finally {
514
+ clearTimeout(timeout);
515
+ if (parentSignal) {
516
+ parentSignal.removeEventListener('abort', abortFromParent);
517
+ }
518
+ }
519
+ }
520
+
346
521
  function buildHeaders(
347
522
  init: RequestInit | undefined,
348
523
  accessToken: string,
@@ -350,10 +525,15 @@ function buildHeaders(
350
525
  sessionId?: string,
351
526
  ): Headers {
352
527
  const headers = new Headers(init?.headers);
528
+ const prior = readSessionState(sessionId);
529
+ const windowId = sessionId
530
+ ? (prior?.windowId ?? getCodexWindowId(sessionId))
531
+ : undefined;
353
532
  headers.delete('Authorization');
354
533
  headers.delete('authorization');
355
534
  headers.set('authorization', `Bearer ${accessToken}`);
356
535
  headers.set('originator', 'otto');
536
+ headers.set('x-codex-installation-id', CODEX_INSTALLATION_ID);
357
537
  headers.set(
358
538
  'User-Agent',
359
539
  `otto/1.0 (${os.platform()} ${os.release()}; ${os.arch()})`,
@@ -363,10 +543,30 @@ function buildHeaders(
363
543
  }
364
544
  if (sessionId) {
365
545
  headers.set('session_id', sessionId);
546
+ headers.set('thread_id', sessionId);
547
+ headers.set('x-codex-window-id', windowId ?? getCodexWindowId(sessionId));
548
+ if (prior?.turnState) {
549
+ headers.set('x-codex-turn-state', prior.turnState);
550
+ }
366
551
  }
367
552
  return headers;
368
553
  }
369
554
 
555
+ function trackCodexResponseHeaders(response: Response, sessionId?: string) {
556
+ if (!sessionId) return;
557
+ const turnState = response.headers.get('x-codex-turn-state') ?? undefined;
558
+ if (!turnState) return;
559
+ const windowId = getCodexWindowId(sessionId);
560
+ mergeSessionState(sessionId, {
561
+ turnState,
562
+ installationId: CODEX_INSTALLATION_ID,
563
+ windowId,
564
+ });
565
+ logOpenAIOAuth(
566
+ `tracked x-codex-turn-state for session=${sessionId} window=${windowId}`,
567
+ );
568
+ }
569
+
370
570
  export function createOpenAIOAuthFetch(config: OpenAIOAuthConfig) {
371
571
  let currentOAuth = config.oauth;
372
572
 
@@ -403,6 +603,9 @@ export function createOpenAIOAuthFetch(config: OpenAIOAuthConfig) {
403
603
  model: requestModel,
404
604
  status: prior?.status,
405
605
  incompleteReason: prior?.incompleteReason,
606
+ turnState: prior?.turnState,
607
+ installationId: prior?.installationId,
608
+ windowId: prior?.windowId,
406
609
  });
407
610
  }
408
611
  }
@@ -425,12 +628,19 @@ export function createOpenAIOAuthFetch(config: OpenAIOAuthConfig) {
425
628
 
426
629
  let response: Response;
427
630
  try {
428
- response = await fetch(targetUrl, {
429
- ...requestInit,
430
- headers,
431
- // @ts-expect-error Bun-specific fetch option
432
- timeout: false,
433
- });
631
+ response = await fetchWithCodexRequestTimeout(
632
+ targetUrl,
633
+ {
634
+ ...requestInit,
635
+ headers,
636
+ },
637
+ {
638
+ enabled: isResponsesRequest,
639
+ sessionId: config.sessionId,
640
+ model: requestModel,
641
+ requestStartedAt,
642
+ },
643
+ );
434
644
  } catch (error) {
435
645
  loggerWarn('[openai-oauth] request failed before response', {
436
646
  sessionId: config.sessionId,
@@ -453,6 +663,9 @@ export function createOpenAIOAuthFetch(config: OpenAIOAuthConfig) {
453
663
  bodyCharsApprox: requestBodySize,
454
664
  model: requestModel,
455
665
  });
666
+ if (isResponsesRequest) {
667
+ trackCodexResponseHeaders(response, config.sessionId);
668
+ }
456
669
  if (!response.ok && response.status !== 401) {
457
670
  loggerWarn('[openai-oauth] non-OK response', {
458
671
  sessionId: config.sessionId,
@@ -499,12 +712,22 @@ export function createOpenAIOAuthFetch(config: OpenAIOAuthConfig) {
499
712
  );
500
713
 
501
714
  const retryStartedAt = Date.now();
502
- const retryResponse = await fetch(targetUrl, {
503
- ...requestInit,
504
- headers: retryHeaders,
505
- // @ts-expect-error Bun-specific fetch option
506
- timeout: false,
507
- });
715
+ const retryResponse = await fetchWithCodexRequestTimeout(
716
+ targetUrl,
717
+ {
718
+ ...requestInit,
719
+ headers: retryHeaders,
720
+ },
721
+ {
722
+ enabled: isResponsesRequest,
723
+ sessionId: config.sessionId,
724
+ model: requestModel,
725
+ requestStartedAt: retryStartedAt,
726
+ },
727
+ );
728
+ if (isResponsesRequest) {
729
+ trackCodexResponseHeaders(retryResponse, config.sessionId);
730
+ }
508
731
  loggerDebug('[openai-oauth] retry response received', {
509
732
  sessionId: config.sessionId,
510
733
  target: isResponsesRequest ? 'codex.responses' : 'other',