throughline 0.3.24 → 0.4.0

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 (116) hide show
  1. package/.claude/commands/tl.md +6 -21
  2. package/.codex-sidecar.yml +62 -0
  3. package/CHANGELOG.md +632 -0
  4. package/README.ja.md +71 -46
  5. package/README.md +420 -76
  6. package/bin/throughline.mjs +169 -7
  7. package/codex/skills/throughline/SKILL.md +157 -0
  8. package/codex/skills/throughline/agents/openai.yaml +7 -0
  9. package/docs/INHERITANCE_ON_CLEAR_ONLY.md +159 -0
  10. package/docs/L1_L2_L3_REDESIGN.md +415 -0
  11. package/docs/PUBLIC_RELEASE_PLAN.md +185 -0
  12. package/docs/THROUGHLINE_CLEAR_AUTO_HANDOFF_PLAN.md +286 -0
  13. package/docs/THROUGHLINE_CODEX_DUAL_SUPPORT.md +249 -0
  14. package/docs/THROUGHLINE_CODEX_FIRST_ROADMAP.md +555 -0
  15. package/docs/THROUGHLINE_CODEX_MONITOR_IMPLEMENTATION_PLAN.md +220 -0
  16. package/docs/THROUGHLINE_CODEX_TRIM_IMPLEMENTATION_PLAN.md +528 -0
  17. package/docs/THROUGHLINE_CODEX_TRIM_ROLLBACK_FIX_PLAN.md +672 -0
  18. package/docs/archive/CONCEPT.md +476 -0
  19. package/docs/archive/EXPERIMENT.md +371 -0
  20. package/docs/archive/README.md +22 -0
  21. package/docs/archive/SESSION_LINKING_DESIGN.md +231 -0
  22. package/docs/archive/THROUGHLINE_NEXT_STEPS.md +134 -0
  23. package/docs/throughline-codex-trim-rollback-incident-report.md +306 -0
  24. package/docs/throughline-handoff-context.example.json +57 -0
  25. package/docs/throughline-rollback-context-trim-insight.md +455 -0
  26. package/package.json +6 -2
  27. package/src/baton.mjs +17 -45
  28. package/src/baton.test.mjs +4 -41
  29. package/src/cli/codex-capture.mjs +95 -0
  30. package/src/cli/codex-handoff-model-smoke.mjs +292 -0
  31. package/src/cli/codex-handoff-model-smoke.test.mjs +262 -0
  32. package/src/cli/codex-handoff-smoke.mjs +163 -0
  33. package/src/cli/codex-handoff-smoke.test.mjs +149 -0
  34. package/src/cli/codex-handoff-start.mjs +291 -0
  35. package/src/cli/codex-handoff-start.test.mjs +194 -0
  36. package/src/cli/codex-hook.mjs +276 -0
  37. package/src/cli/codex-hook.test.mjs +293 -0
  38. package/src/cli/codex-host-primitive-audit.mjs +110 -0
  39. package/src/cli/codex-host-primitive-audit.test.mjs +75 -0
  40. package/src/cli/codex-restore-smoke.mjs +357 -0
  41. package/src/cli/codex-restore-source-audit.mjs +304 -0
  42. package/src/cli/codex-resume.mjs +138 -0
  43. package/src/cli/codex-rollback-model-visible-smoke.mjs +373 -0
  44. package/src/cli/codex-rollback-model-visible-smoke.test.mjs +255 -0
  45. package/src/cli/codex-sidecar-diagnostics.mjs +48 -0
  46. package/src/cli/codex-sidecar-dry-run.mjs +85 -0
  47. package/src/cli/codex-summarize.mjs +224 -0
  48. package/src/cli/codex-threads.mjs +89 -0
  49. package/src/cli/codex-visibility-smoke.mjs +196 -0
  50. package/src/cli/codex-vscode-restore-smoke.mjs +226 -0
  51. package/src/cli/codex-vscode-rollback-smoke.mjs +114 -0
  52. package/src/cli/doctor.mjs +503 -1
  53. package/src/cli/doctor.test.mjs +542 -3
  54. package/src/cli/handoff-preview.mjs +78 -0
  55. package/src/cli/help.test.mjs +64 -0
  56. package/src/cli/install.mjs +226 -3
  57. package/src/cli/install.test.mjs +205 -4
  58. package/src/cli/trim.mjs +564 -0
  59. package/src/codex-app-server.mjs +1816 -0
  60. package/src/codex-app-server.test.mjs +512 -0
  61. package/src/codex-auto-refresh.mjs +194 -0
  62. package/src/codex-auto-refresh.test.mjs +182 -0
  63. package/src/codex-capture.mjs +235 -0
  64. package/src/codex-capture.test.mjs +393 -0
  65. package/src/codex-handoff-model-smoke.mjs +114 -0
  66. package/src/codex-handoff-model-smoke.test.mjs +89 -0
  67. package/src/codex-handoff-smoke.mjs +124 -0
  68. package/src/codex-handoff-smoke.test.mjs +103 -0
  69. package/src/codex-handoff.mjs +331 -0
  70. package/src/codex-handoff.test.mjs +220 -0
  71. package/src/codex-host-primitive-audit.mjs +374 -0
  72. package/src/codex-host-primitive-audit.test.mjs +208 -0
  73. package/src/codex-restore-smoke.test.mjs +639 -0
  74. package/src/codex-restore-source-audit.mjs +1348 -0
  75. package/src/codex-restore-source-audit.test.mjs +623 -0
  76. package/src/codex-resume.test.mjs +242 -0
  77. package/src/codex-rollout-memory.mjs +711 -0
  78. package/src/codex-rollout-memory.test.mjs +610 -0
  79. package/src/codex-sidecar-cli.test.mjs +75 -0
  80. package/src/codex-sidecar.mjs +246 -0
  81. package/src/codex-sidecar.test.mjs +172 -0
  82. package/src/codex-summarize.test.mjs +143 -0
  83. package/src/codex-thread-identity.mjs +23 -0
  84. package/src/codex-thread-index.mjs +173 -0
  85. package/src/codex-thread-index.test.mjs +164 -0
  86. package/src/codex-usage.mjs +110 -0
  87. package/src/codex-usage.test.mjs +140 -0
  88. package/src/codex-visibility-smoke.test.mjs +222 -0
  89. package/src/codex-vscode-restore-smoke.mjs +206 -0
  90. package/src/codex-vscode-restore-smoke.test.mjs +325 -0
  91. package/src/codex-vscode-rollback-smoke.mjs +90 -0
  92. package/src/codex-vscode-rollback-smoke.test.mjs +290 -0
  93. package/src/db-schema.test.mjs +96 -0
  94. package/src/db.mjs +14 -1
  95. package/src/haiku-summarizer.mjs +267 -26
  96. package/src/haiku-summarizer.test.mjs +282 -0
  97. package/src/handoff-preview.test.mjs +108 -0
  98. package/src/handoff-record.mjs +294 -0
  99. package/src/handoff-record.test.mjs +226 -0
  100. package/src/hook-entrypoints.test.mjs +286 -0
  101. package/src/package-files.test.mjs +19 -0
  102. package/src/prompt-submit.mjs +9 -6
  103. package/src/resume-context.mjs +58 -171
  104. package/src/resume-context.test.mjs +177 -0
  105. package/src/session-start.mjs +85 -26
  106. package/src/state-file.mjs +50 -6
  107. package/src/state-file.test.mjs +50 -0
  108. package/src/token-monitor.mjs +14 -10
  109. package/src/token-monitor.test.mjs +27 -0
  110. package/src/trim-cli.test.mjs +1584 -0
  111. package/src/trim-model.mjs +584 -0
  112. package/src/trim-model.test.mjs +568 -0
  113. package/src/turn-processor.mjs +17 -10
  114. package/src/vscode-task.mjs +33 -10
  115. package/src/vscode-task.test.mjs +19 -9
  116. package/src/cli/save-inflight.mjs +0 -81
@@ -0,0 +1,1816 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { randomUUID } from 'node:crypto';
3
+
4
+ export const CODEX_APP_SERVER_METHODS = Object.freeze({
5
+ initialize: 'initialize',
6
+ initialized: 'initialized',
7
+ threadRead: 'thread/read',
8
+ threadResume: 'thread/resume',
9
+ threadTurnsList: 'thread/turns/list',
10
+ threadRollback: 'thread/rollback',
11
+ threadInjectItems: 'thread/inject_items',
12
+ turnStart: 'turn/start',
13
+ });
14
+
15
+ const MAX_APP_SERVER_STDERR_CHARS = 4000;
16
+
17
+ export function encodeAppServerMessage(message) {
18
+ if (!isRecord(message)) {
19
+ throw new Error('encodeAppServerMessage: message must be an object');
20
+ }
21
+ return `${JSON.stringify(message)}\n`;
22
+ }
23
+
24
+ export function parseAppServerLine(line) {
25
+ if (typeof line !== 'string') {
26
+ throw new Error('parseAppServerLine: line must be a string');
27
+ }
28
+
29
+ const trimmed = line.trim();
30
+ if (!trimmed) {
31
+ throw new Error('parseAppServerLine: line must not be empty');
32
+ }
33
+
34
+ let parsed;
35
+ try {
36
+ parsed = JSON.parse(trimmed);
37
+ } catch (err) {
38
+ const msg = err instanceof Error ? err.message : 'unknown';
39
+ throw new Error(`parseAppServerLine: invalid JSON: ${msg}`);
40
+ }
41
+
42
+ if (!isRecord(parsed)) {
43
+ throw new Error('parseAppServerLine: decoded message must be an object');
44
+ }
45
+
46
+ if ('error' in parsed && 'id' in parsed) {
47
+ if (!isRequestId(parsed.id) || !isRecord(parsed.error)) {
48
+ throw new Error('parseAppServerLine: invalid error response');
49
+ }
50
+ const { code, message, data } = parsed.error;
51
+ if (typeof code !== 'number' || typeof message !== 'string') {
52
+ throw new Error('parseAppServerLine: invalid error response');
53
+ }
54
+ return {
55
+ kind: 'error',
56
+ id: parsed.id,
57
+ error: { code, message, data },
58
+ };
59
+ }
60
+
61
+ if ('result' in parsed && 'id' in parsed) {
62
+ if (!isRequestId(parsed.id)) {
63
+ throw new Error('parseAppServerLine: invalid response id');
64
+ }
65
+ return {
66
+ kind: 'response',
67
+ id: parsed.id,
68
+ result: parsed.result,
69
+ };
70
+ }
71
+
72
+ if (typeof parsed.method === 'string') {
73
+ if ('id' in parsed) {
74
+ if (!isRequestId(parsed.id)) {
75
+ throw new Error('parseAppServerLine: invalid server request id');
76
+ }
77
+ return {
78
+ kind: 'request',
79
+ id: parsed.id,
80
+ method: parsed.method,
81
+ params: parsed.params,
82
+ };
83
+ }
84
+ return {
85
+ kind: 'notification',
86
+ method: parsed.method,
87
+ params: parsed.params,
88
+ };
89
+ }
90
+
91
+ throw new Error('parseAppServerLine: unrecognized message shape');
92
+ }
93
+
94
+ export function buildInitializeRequest({
95
+ id,
96
+ clientName = 'throughline',
97
+ clientTitle = 'Throughline',
98
+ version = '0.0.0',
99
+ optOutNotificationMethods = [],
100
+ }) {
101
+ assertRequestId(id, 'buildInitializeRequest');
102
+ if (!Array.isArray(optOutNotificationMethods)) {
103
+ throw new Error('buildInitializeRequest: optOutNotificationMethods must be an array');
104
+ }
105
+ return {
106
+ id,
107
+ method: CODEX_APP_SERVER_METHODS.initialize,
108
+ params: {
109
+ clientInfo: {
110
+ name: clientName,
111
+ title: clientTitle,
112
+ version,
113
+ },
114
+ capabilities: {
115
+ experimentalApi: true,
116
+ optOutNotificationMethods,
117
+ },
118
+ },
119
+ };
120
+ }
121
+
122
+ export function buildInitializedNotification() {
123
+ return {
124
+ method: CODEX_APP_SERVER_METHODS.initialized,
125
+ };
126
+ }
127
+
128
+ export function buildThreadReadRequest({ id, threadId, includeTurns = true }) {
129
+ assertRequestId(id, 'buildThreadReadRequest');
130
+ assertNonEmptyString(threadId, 'buildThreadReadRequest: threadId');
131
+ return {
132
+ id,
133
+ method: CODEX_APP_SERVER_METHODS.threadRead,
134
+ params: {
135
+ threadId,
136
+ includeTurns: Boolean(includeTurns),
137
+ },
138
+ };
139
+ }
140
+
141
+ export function buildThreadResumeRequest({
142
+ id,
143
+ threadId,
144
+ cwd,
145
+ approvalPolicy = null,
146
+ sandbox = null,
147
+ model = null,
148
+ excludeTurns = false,
149
+ }) {
150
+ assertRequestId(id, 'buildThreadResumeRequest');
151
+ assertNonEmptyString(threadId, 'buildThreadResumeRequest: threadId');
152
+ return compactNullish({
153
+ id,
154
+ method: CODEX_APP_SERVER_METHODS.threadResume,
155
+ params: compactNullish({
156
+ threadId,
157
+ cwd,
158
+ approvalPolicy,
159
+ sandbox,
160
+ model,
161
+ excludeTurns: Boolean(excludeTurns),
162
+ }),
163
+ });
164
+ }
165
+
166
+ export function buildThreadTurnsListRequest({
167
+ id,
168
+ threadId,
169
+ cursor = null,
170
+ limit = null,
171
+ sortDirection = 'asc',
172
+ }) {
173
+ assertRequestId(id, 'buildThreadTurnsListRequest');
174
+ assertNonEmptyString(threadId, 'buildThreadTurnsListRequest: threadId');
175
+ if (cursor !== null && cursor !== undefined) {
176
+ assertNonEmptyString(cursor, 'buildThreadTurnsListRequest: cursor');
177
+ }
178
+ if (limit !== null && limit !== undefined) {
179
+ assertPositiveInteger(limit, 'buildThreadTurnsListRequest: limit');
180
+ }
181
+ if (sortDirection !== null && sortDirection !== undefined && sortDirection !== 'asc' && sortDirection !== 'desc') {
182
+ throw new Error('buildThreadTurnsListRequest: sortDirection must be asc or desc');
183
+ }
184
+ return {
185
+ id,
186
+ method: CODEX_APP_SERVER_METHODS.threadTurnsList,
187
+ params: compactNullish({
188
+ threadId,
189
+ cursor,
190
+ limit,
191
+ sortDirection,
192
+ }),
193
+ };
194
+ }
195
+
196
+ export function buildThreadRollbackRequest({ id, threadId, numTurns }) {
197
+ assertRequestId(id, 'buildThreadRollbackRequest');
198
+ assertNonEmptyString(threadId, 'buildThreadRollbackRequest: threadId');
199
+ if (!Number.isInteger(numTurns) || numTurns < 1) {
200
+ throw new Error('buildThreadRollbackRequest: numTurns must be an integer >= 1');
201
+ }
202
+ return {
203
+ id,
204
+ method: CODEX_APP_SERVER_METHODS.threadRollback,
205
+ params: {
206
+ threadId,
207
+ numTurns,
208
+ },
209
+ };
210
+ }
211
+
212
+ export function buildThreadInjectItemsRequest({ id, threadId, items }) {
213
+ assertRequestId(id, 'buildThreadInjectItemsRequest');
214
+ assertNonEmptyString(threadId, 'buildThreadInjectItemsRequest: threadId');
215
+ if (!Array.isArray(items)) {
216
+ throw new Error('buildThreadInjectItemsRequest: items must be an array');
217
+ }
218
+ return {
219
+ id,
220
+ method: CODEX_APP_SERVER_METHODS.threadInjectItems,
221
+ params: {
222
+ threadId,
223
+ items,
224
+ },
225
+ };
226
+ }
227
+
228
+ export function buildTurnStartRequest({ id, threadId, text }) {
229
+ assertRequestId(id, 'buildTurnStartRequest');
230
+ assertNonEmptyString(threadId, 'buildTurnStartRequest: threadId');
231
+ assertNonEmptyString(text, 'buildTurnStartRequest: text');
232
+ return {
233
+ id,
234
+ method: CODEX_APP_SERVER_METHODS.turnStart,
235
+ params: {
236
+ threadId,
237
+ input: [buildTextInputItem(text)],
238
+ },
239
+ };
240
+ }
241
+
242
+ export function buildTextInputItem(text) {
243
+ assertNonEmptyString(text, 'buildTextInputItem: text');
244
+ return {
245
+ type: 'text',
246
+ text,
247
+ text_elements: [],
248
+ };
249
+ }
250
+
251
+ export function buildDeveloperMessageItem(text) {
252
+ assertNonEmptyString(text, 'buildDeveloperMessageItem: text');
253
+ return {
254
+ type: 'message',
255
+ role: 'developer',
256
+ content: [{ type: 'input_text', text }],
257
+ };
258
+ }
259
+
260
+ export async function runCodexTrimPreflight({
261
+ threadId,
262
+ cwd,
263
+ rollbackTurns,
264
+ expectedTurns = null,
265
+ command = 'codex',
266
+ commandArgs = ['app-server', '--listen', 'stdio://'],
267
+ timeoutMs = 30_000,
268
+ requestTimeoutMs = 10_000,
269
+ } = {}) {
270
+ assertNonEmptyString(threadId, 'runCodexTrimPreflight: threadId');
271
+ assertNonEmptyString(cwd, 'runCodexTrimPreflight: cwd');
272
+ assertNonEmptyString(command, 'runCodexTrimPreflight: command');
273
+ if (!Number.isInteger(rollbackTurns) || rollbackTurns < 1) {
274
+ throw new Error('runCodexTrimPreflight: rollbackTurns must be an integer >= 1');
275
+ }
276
+ assertOptionalTurnCount(expectedTurns, 'runCodexTrimPreflight: expectedTurns');
277
+ if (!Array.isArray(commandArgs)) {
278
+ throw new Error('runCodexTrimPreflight: commandArgs must be an array');
279
+ }
280
+
281
+ const client = startAppServerClient({
282
+ command,
283
+ args: commandArgs,
284
+ cwd,
285
+ timeoutMs,
286
+ requestTimeoutMs,
287
+ });
288
+
289
+ try {
290
+ await client.request(
291
+ buildInitializeRequest({
292
+ id: randomUUID(),
293
+ clientName: 'throughline-trim',
294
+ clientTitle: 'Throughline Trim',
295
+ }),
296
+ );
297
+ client.notify(buildInitializedNotification());
298
+
299
+ const beforeRead = await client.request(
300
+ buildThreadReadRequest({
301
+ id: randomUUID(),
302
+ threadId,
303
+ includeTurns: true,
304
+ }),
305
+ );
306
+ const resumed = await client.request(
307
+ buildThreadResumeRequest({
308
+ id: randomUUID(),
309
+ threadId,
310
+ cwd,
311
+ excludeTurns: false,
312
+ }),
313
+ );
314
+ const readTurns = countTurns(beforeRead);
315
+ const resumedTurns = countTurns(resumed);
316
+
317
+ return {
318
+ status: 'preflight-ready',
319
+ threadId,
320
+ rollbackSent: false,
321
+ injectSent: false,
322
+ readTurns,
323
+ resumedTurns,
324
+ turnCountCheck: compareTurnCounts({
325
+ expectedTurns,
326
+ readTurns,
327
+ resumedTurns,
328
+ }),
329
+ rollbackRequestPreview: buildThreadRollbackRequest({
330
+ id: 'rollback-preview',
331
+ threadId,
332
+ numTurns: rollbackTurns,
333
+ }),
334
+ notifications: [...new Set(client.notifications)],
335
+ stderr: client.stderr,
336
+ };
337
+ } finally {
338
+ await client.close();
339
+ }
340
+ }
341
+
342
+ export async function runCodexThreadRestoreSmoke({
343
+ threadId,
344
+ cwd,
345
+ expectedTurns = null,
346
+ restoreTextNeedles = [],
347
+ command = 'codex',
348
+ commandArgs = ['app-server', '--listen', 'stdio://'],
349
+ timeoutMs = 30_000,
350
+ requestTimeoutMs = 10_000,
351
+ cycles = 2,
352
+ turnsListLimit = 200,
353
+ maxTurnsListPages = 50,
354
+ } = {}) {
355
+ assertNonEmptyString(threadId, 'runCodexThreadRestoreSmoke: threadId');
356
+ assertNonEmptyString(cwd, 'runCodexThreadRestoreSmoke: cwd');
357
+ assertNonEmptyString(command, 'runCodexThreadRestoreSmoke: command');
358
+ assertOptionalTurnCount(expectedTurns, 'runCodexThreadRestoreSmoke: expectedTurns');
359
+ assertRestoreTextNeedles(restoreTextNeedles, 'runCodexThreadRestoreSmoke: restoreTextNeedles');
360
+ if (!Array.isArray(commandArgs)) {
361
+ throw new Error('runCodexThreadRestoreSmoke: commandArgs must be an array');
362
+ }
363
+ assertPositiveInteger(cycles, 'runCodexThreadRestoreSmoke: cycles');
364
+ assertPositiveInteger(turnsListLimit, 'runCodexThreadRestoreSmoke: turnsListLimit');
365
+ assertPositiveInteger(maxTurnsListPages, 'runCodexThreadRestoreSmoke: maxTurnsListPages');
366
+
367
+ const observations = [];
368
+ for (let cycle = 1; cycle <= cycles; cycle++) {
369
+ observations.push(
370
+ await readCodexThreadWithFreshAppServer({
371
+ cycle,
372
+ threadId,
373
+ cwd,
374
+ expectedTurns,
375
+ restoreTextNeedles,
376
+ command,
377
+ commandArgs,
378
+ timeoutMs,
379
+ requestTimeoutMs,
380
+ turnsListLimit,
381
+ maxTurnsListPages,
382
+ }),
383
+ );
384
+ }
385
+
386
+ const baseline = observations[0] ?? null;
387
+ const stableAcrossCycles =
388
+ Boolean(baseline) &&
389
+ observations.every(
390
+ (observation) =>
391
+ observation.readTurns === baseline.readTurns &&
392
+ observation.resumedTurns === baseline.resumedTurns &&
393
+ observation.turnsListTurns === baseline.turnsListTurns &&
394
+ observation.turnsListComplete === baseline.turnsListComplete,
395
+ );
396
+ const turnCountsMatchExpected = observations.every(
397
+ (observation) => observation.turnCountCheck.status === 'match',
398
+ );
399
+ const turnCountsKnown = observations.every(
400
+ (observation) =>
401
+ Number.isInteger(observation.readTurns) &&
402
+ Number.isInteger(observation.resumedTurns) &&
403
+ Number.isInteger(observation.turnsListTurns) &&
404
+ observation.turnsListComplete === true,
405
+ );
406
+ const restoreTextMatchCheck = summarizeAppServerRestoreTextMatches(observations);
407
+ const status =
408
+ restoreTextMatchCheck.status === 'matches-found' &&
409
+ restoreTextMatchCheck.hasBlockingCandidates === true
410
+ ? 'app-server-restore-text-retained'
411
+ : restoreTextMatchCheck.status === 'matches-found'
412
+ ? 'app-server-restore-text-quoted'
413
+ : stableAcrossCycles && (expectedTurns === null || expectedTurns === undefined
414
+ ? turnCountsKnown
415
+ : turnCountsMatchExpected)
416
+ ? 'app-server-restart-stable'
417
+ : 'app-server-restart-mismatch';
418
+
419
+ return {
420
+ status,
421
+ reason:
422
+ status === 'app-server-restore-text-retained'
423
+ ? 'restore_text_seen_in_app_server_response'
424
+ : status === 'app-server-restore-text-quoted'
425
+ ? 'restore_text_seen_only_in_quoted_or_output_response_fields'
426
+ : status === 'app-server-restart-stable'
427
+ ? 'fresh_app_server_restore_counts_stable'
428
+ : 'fresh_app_server_restore_counts_mismatch',
429
+ proofScope: 'app_server_process_restart_only',
430
+ restartSafe: false,
431
+ threadId,
432
+ expectedTurns,
433
+ cycles,
434
+ restoreTextNeedles: restoreTextNeedles.map(({ id, textPreview }) => ({ id, textPreview })),
435
+ restoreTextMatchCheck,
436
+ observations,
437
+ };
438
+ }
439
+
440
+ export async function runCodexDeveloperMemoryInject({
441
+ threadId,
442
+ cwd,
443
+ memoryText,
444
+ command = 'codex',
445
+ commandArgs = ['app-server', '--listen', 'stdio://'],
446
+ timeoutMs = 30_000,
447
+ requestTimeoutMs = 10_000,
448
+ } = {}) {
449
+ assertNonEmptyString(threadId, 'runCodexDeveloperMemoryInject: threadId');
450
+ assertNonEmptyString(cwd, 'runCodexDeveloperMemoryInject: cwd');
451
+ assertNonEmptyString(memoryText, 'runCodexDeveloperMemoryInject: memoryText');
452
+ assertNonEmptyString(command, 'runCodexDeveloperMemoryInject: command');
453
+ if (!Array.isArray(commandArgs)) {
454
+ throw new Error('runCodexDeveloperMemoryInject: commandArgs must be an array');
455
+ }
456
+
457
+ const client = startAppServerClient({
458
+ command,
459
+ args: commandArgs,
460
+ cwd,
461
+ timeoutMs,
462
+ requestTimeoutMs,
463
+ });
464
+
465
+ try {
466
+ await client.request(
467
+ buildInitializeRequest({
468
+ id: randomUUID(),
469
+ clientName: 'throughline-codex-memory-inject',
470
+ clientTitle: 'Throughline Codex Memory Inject',
471
+ }),
472
+ );
473
+ client.notify(buildInitializedNotification());
474
+
475
+ const beforeRead = await client.request(
476
+ buildThreadReadRequest({
477
+ id: randomUUID(),
478
+ threadId,
479
+ includeTurns: true,
480
+ }),
481
+ );
482
+ const resumed = await client.request(
483
+ buildThreadResumeRequest({
484
+ id: randomUUID(),
485
+ threadId,
486
+ cwd,
487
+ approvalPolicy: 'never',
488
+ sandbox: 'read-only',
489
+ excludeTurns: false,
490
+ }),
491
+ );
492
+ const inject = await client.request(
493
+ buildThreadInjectItemsRequest({
494
+ id: randomUUID(),
495
+ threadId,
496
+ items: [buildDeveloperMessageItem(memoryText)],
497
+ }),
498
+ );
499
+
500
+ return {
501
+ status: 'injected',
502
+ reason: 'developer_memory_injected',
503
+ threadId,
504
+ readTurns: countTurns(beforeRead),
505
+ resumedTurns: countTurns(resumed),
506
+ injectResultTurns: countTurns(inject),
507
+ injectSent: true,
508
+ injectedItems: 1,
509
+ notifications: [...new Set(client.notifications)],
510
+ stderr: client.stderr,
511
+ };
512
+ } finally {
513
+ await client.close();
514
+ }
515
+ }
516
+
517
+ async function readCodexThreadWithFreshAppServer({
518
+ cycle,
519
+ threadId,
520
+ cwd,
521
+ expectedTurns,
522
+ restoreTextNeedles,
523
+ command,
524
+ commandArgs,
525
+ timeoutMs,
526
+ requestTimeoutMs,
527
+ turnsListLimit,
528
+ maxTurnsListPages,
529
+ }) {
530
+ const client = startAppServerClient({
531
+ command,
532
+ args: commandArgs,
533
+ cwd,
534
+ timeoutMs,
535
+ requestTimeoutMs,
536
+ });
537
+
538
+ try {
539
+ await client.request(
540
+ buildInitializeRequest({
541
+ id: randomUUID(),
542
+ clientName: 'throughline-codex-restore-smoke',
543
+ clientTitle: 'Throughline Codex Restore Smoke',
544
+ }),
545
+ );
546
+ client.notify(buildInitializedNotification());
547
+
548
+ const beforeRead = await client.request(
549
+ buildThreadReadRequest({
550
+ id: randomUUID(),
551
+ threadId,
552
+ includeTurns: true,
553
+ }),
554
+ );
555
+ const resumed = await client.request(
556
+ buildThreadResumeRequest({
557
+ id: randomUUID(),
558
+ threadId,
559
+ cwd,
560
+ excludeTurns: false,
561
+ }),
562
+ );
563
+ const turnsList = await listAllCodexThreadTurns({
564
+ client,
565
+ threadId,
566
+ limit: turnsListLimit,
567
+ maxPages: maxTurnsListPages,
568
+ });
569
+ const readTurns = countTurns(beforeRead);
570
+ const resumedTurns = countTurns(resumed);
571
+ const turnsListTurns = turnsList.turns.length;
572
+ const responseTextMatches = inspectAppServerRestoreTextMatches({
573
+ readResult: beforeRead,
574
+ resumeResult: resumed,
575
+ turnsListTurns: turnsList.turns,
576
+ needles: restoreTextNeedles,
577
+ });
578
+
579
+ return {
580
+ cycle,
581
+ readTurns,
582
+ resumedTurns,
583
+ turnsListTurns,
584
+ turnsListPages: turnsList.pages,
585
+ turnsListComplete: turnsList.complete,
586
+ turnsListNextCursor: turnsList.nextCursor,
587
+ responseTextMatches,
588
+ turnCountCheck: compareRestoreTurnCounts({
589
+ expectedTurns,
590
+ readTurns,
591
+ resumedTurns,
592
+ turnsListTurns,
593
+ turnsListComplete: turnsList.complete,
594
+ }),
595
+ notifications: [...new Set(client.notifications)],
596
+ stderr: client.stderr,
597
+ };
598
+ } finally {
599
+ await client.close();
600
+ }
601
+ }
602
+
603
+ function inspectAppServerRestoreTextMatches({ readResult, resumeResult, turnsListTurns, needles }) {
604
+ if (!Array.isArray(needles) || needles.length === 0) {
605
+ return {
606
+ status: 'unchecked',
607
+ reason: 'restore_text_needles_not_provided',
608
+ sources: [],
609
+ matchedNeedles: [],
610
+ };
611
+ }
612
+
613
+ const sources = [
614
+ { id: 'thread_read', value: readResult },
615
+ { id: 'thread_resume', value: resumeResult },
616
+ { id: 'thread_turns_list', value: turnsListTurns },
617
+ ].map((source) => inspectTextMatchesInValue(source, needles));
618
+ const matchedIds = new Set();
619
+ for (const source of sources) {
620
+ for (const match of source.matches) matchedIds.add(match.id);
621
+ }
622
+ const locations = sources.flatMap((source) =>
623
+ (source.matches ?? []).flatMap((match) => match.locations ?? []),
624
+ );
625
+ const blockingKinds = [
626
+ ...new Set(
627
+ locations
628
+ .filter((location) => location.blockingCandidate)
629
+ .map((location) => location.kind),
630
+ ),
631
+ ];
632
+ const nonBlockingKinds = [
633
+ ...new Set(
634
+ locations
635
+ .filter((location) => !location.blockingCandidate)
636
+ .map((location) => location.kind),
637
+ ),
638
+ ];
639
+ const locationRisks = [
640
+ ...new Set(locations.map((location) => location.risk).filter(Boolean)),
641
+ ];
642
+
643
+ return {
644
+ status: matchedIds.size > 0 ? 'matches-found' : 'no-matches',
645
+ reason:
646
+ matchedIds.size > 0
647
+ ? 'restore_text_seen_in_app_server_response'
648
+ : 'restore_text_not_seen_in_app_server_response',
649
+ hasBlockingCandidates: matchedIds.size > 0 ? blockingKinds.length > 0 : false,
650
+ blockingKinds,
651
+ nonBlockingKinds,
652
+ locationRisks,
653
+ sources,
654
+ matchedNeedles: needles
655
+ .filter((needle) => matchedIds.has(needle.id))
656
+ .map(({ id, textPreview }) => ({ id, textPreview })),
657
+ };
658
+ }
659
+
660
+ function summarizeAppServerRestoreTextMatches(observations) {
661
+ const summaries = observations
662
+ .map((observation) => ({
663
+ cycle: observation.cycle,
664
+ responseTextMatches: observation.responseTextMatches,
665
+ }))
666
+ .filter(({ responseTextMatches }) => Boolean(responseTextMatches));
667
+ if (summaries.length === 0) {
668
+ return {
669
+ status: 'unchecked',
670
+ reason: 'restore_text_match_observations_not_available',
671
+ sources: [],
672
+ matchedNeedles: [],
673
+ };
674
+ }
675
+
676
+ const matchedNeedles = new Map();
677
+ const sources = new Map();
678
+ for (const { cycle, responseTextMatches } of summaries) {
679
+ for (const needle of responseTextMatches.matchedNeedles ?? []) {
680
+ if (!matchedNeedles.has(needle.id)) matchedNeedles.set(needle.id, needle);
681
+ }
682
+ for (const source of responseTextMatches.sources ?? []) {
683
+ const matches = source.matches ?? [];
684
+ if (matches.length === 0) continue;
685
+ if (!sources.has(source.source)) {
686
+ sources.set(source.source, {
687
+ source: source.source,
688
+ cycles: new Set(),
689
+ matchedNeedleIds: new Set(),
690
+ });
691
+ }
692
+ const sourceSummary = sources.get(source.source);
693
+ sourceSummary.cycles.add(cycle);
694
+ if (!sourceSummary.samplePaths) sourceSummary.samplePaths = new Set();
695
+ if (!sourceSummary.locationKinds) sourceSummary.locationKinds = new Set();
696
+ if (!sourceSummary.locationRisks) sourceSummary.locationRisks = new Set();
697
+ if (!sourceSummary.blockingKinds) sourceSummary.blockingKinds = new Set();
698
+ if (!sourceSummary.nonBlockingKinds) sourceSummary.nonBlockingKinds = new Set();
699
+ for (const match of matches) {
700
+ sourceSummary.matchedNeedleIds.add(match.id);
701
+ for (const location of match.locations ?? []) {
702
+ if (sourceSummary.samplePaths.size < 10) {
703
+ sourceSummary.samplePaths.add(location.path);
704
+ }
705
+ sourceSummary.locationKinds.add(location.kind);
706
+ sourceSummary.locationRisks.add(location.risk);
707
+ if (location.blockingCandidate) {
708
+ sourceSummary.blockingKinds.add(location.kind);
709
+ } else {
710
+ sourceSummary.nonBlockingKinds.add(location.kind);
711
+ }
712
+ }
713
+ }
714
+ }
715
+ }
716
+
717
+ if (matchedNeedles.size > 0) {
718
+ const sourceEntries = [...sources.values()];
719
+ const hasBlockingCandidates = sourceEntries.some(
720
+ (source) => source.blockingKinds?.size > 0,
721
+ );
722
+ return {
723
+ status: 'matches-found',
724
+ reason: 'restore_text_seen_in_app_server_response',
725
+ hasBlockingCandidates,
726
+ blockingKinds: [
727
+ ...new Set(sourceEntries.flatMap((source) => [...(source.blockingKinds ?? [])])),
728
+ ],
729
+ nonBlockingKinds: [
730
+ ...new Set(sourceEntries.flatMap((source) => [...(source.nonBlockingKinds ?? [])])),
731
+ ],
732
+ locationRisks: [
733
+ ...new Set(sourceEntries.flatMap((source) => [...(source.locationRisks ?? [])])),
734
+ ],
735
+ sources: sourceEntries.map((source) => ({
736
+ source: source.source,
737
+ cycles: [...source.cycles],
738
+ matchedNeedleIds: [...source.matchedNeedleIds],
739
+ samplePaths: [...(source.samplePaths ?? [])],
740
+ locationKinds: [...(source.locationKinds ?? [])],
741
+ locationRisks: [...(source.locationRisks ?? [])],
742
+ blockingKinds: [...(source.blockingKinds ?? [])],
743
+ nonBlockingKinds: [...(source.nonBlockingKinds ?? [])],
744
+ hasBlockingCandidates: (source.blockingKinds?.size ?? 0) > 0,
745
+ })),
746
+ matchedNeedles: [...matchedNeedles.values()],
747
+ };
748
+ }
749
+
750
+ const checked = summaries.some(
751
+ ({ responseTextMatches }) => responseTextMatches.status !== 'unchecked',
752
+ );
753
+ return {
754
+ status: checked ? 'no-matches' : 'unchecked',
755
+ reason: checked
756
+ ? 'restore_text_not_seen_in_app_server_response'
757
+ : 'restore_text_needles_not_provided',
758
+ sources: [],
759
+ matchedNeedles: [],
760
+ };
761
+ }
762
+
763
+ function inspectTextMatchesInValue(source, needles) {
764
+ const text = safeJsonStringify(source.value);
765
+ const matches = [];
766
+ for (const needle of needles) {
767
+ if (!needle.value || !text.includes(needle.value)) continue;
768
+ const locations = findNeedleLocationsInValue(source.value, needle.value);
769
+ matches.push({
770
+ id: needle.id,
771
+ textPreview: needle.textPreview,
772
+ locations,
773
+ });
774
+ }
775
+ return {
776
+ source: source.id,
777
+ inspectedChars: text.length,
778
+ matches,
779
+ };
780
+ }
781
+
782
+ function findNeedleLocationsInValue(value, needleValue, path = '$', out = []) {
783
+ if (out.length >= 20) return out;
784
+ if (typeof value === 'string') {
785
+ if (value.includes(needleValue)) {
786
+ out.push({
787
+ path,
788
+ ...classifyResponseTextLocation(path),
789
+ valuePreview: value.length > 160 ? `${value.slice(0, 160)}...` : value,
790
+ });
791
+ }
792
+ return out;
793
+ }
794
+ if (Array.isArray(value)) {
795
+ for (let index = 0; index < value.length && out.length < 20; index++) {
796
+ findNeedleLocationsInValue(value[index], needleValue, `${path}[${index}]`, out);
797
+ }
798
+ return out;
799
+ }
800
+ if (isRecord(value)) {
801
+ for (const [key, child] of Object.entries(value)) {
802
+ if (out.length >= 20) break;
803
+ findNeedleLocationsInValue(child, needleValue, `${path}.${jsonPathKey(key)}`, out);
804
+ }
805
+ }
806
+ return out;
807
+ }
808
+
809
+ function jsonPathKey(key) {
810
+ return /^[A-Za-z_$][\w$]*$/.test(key) ? key : JSON.stringify(key);
811
+ }
812
+
813
+ function classifyResponseTextLocation(path) {
814
+ if (path.includes('.replacement_history') || path.includes('.replacementHistory')) {
815
+ return {
816
+ kind: 'replacement_history',
817
+ risk: 'durable_restore_source',
818
+ blockingCandidate: true,
819
+ };
820
+ }
821
+ if (path.endsWith('.aggregatedOutput')) {
822
+ return {
823
+ kind: 'aggregated_output',
824
+ risk: 'quoted_or_tool_output_context',
825
+ blockingCandidate: false,
826
+ };
827
+ }
828
+ if (/\.items\[\d+\]\.text$/.test(path)) {
829
+ return {
830
+ kind: 'item_text_field',
831
+ risk: 'direct_turn_text_candidate',
832
+ blockingCandidate: true,
833
+ };
834
+ }
835
+ if (path.includes('.content[')) {
836
+ return {
837
+ kind: 'content_field',
838
+ risk: 'direct_turn_content_candidate',
839
+ blockingCandidate: true,
840
+ };
841
+ }
842
+ if (path.includes('.turns[')) {
843
+ return {
844
+ kind: 'turn_payload',
845
+ risk: 'unknown_turn_payload_field',
846
+ blockingCandidate: true,
847
+ };
848
+ }
849
+ return {
850
+ kind: 'unknown',
851
+ risk: 'unknown_response_field',
852
+ blockingCandidate: true,
853
+ };
854
+ }
855
+
856
+ function safeJsonStringify(value) {
857
+ try {
858
+ return JSON.stringify(value) ?? '';
859
+ } catch {
860
+ return '';
861
+ }
862
+ }
863
+
864
+ async function listAllCodexThreadTurns({ client, threadId, limit, maxPages }) {
865
+ const turns = [];
866
+ let cursor = null;
867
+ let nextCursor = null;
868
+ for (let page = 1; page <= maxPages; page++) {
869
+ const result = await client.request(
870
+ buildThreadTurnsListRequest({
871
+ id: randomUUID(),
872
+ threadId,
873
+ cursor,
874
+ limit,
875
+ sortDirection: 'asc',
876
+ }),
877
+ );
878
+ const data = isRecord(result) && Array.isArray(result.data) ? result.data : [];
879
+ turns.push(...data);
880
+ nextCursor = isRecord(result) && typeof result.nextCursor === 'string' ? result.nextCursor : null;
881
+ if (!nextCursor) {
882
+ return {
883
+ turns,
884
+ pages: page,
885
+ complete: true,
886
+ nextCursor: null,
887
+ };
888
+ }
889
+ cursor = nextCursor;
890
+ }
891
+
892
+ return {
893
+ turns,
894
+ pages: maxPages,
895
+ complete: false,
896
+ nextCursor,
897
+ };
898
+ }
899
+
900
+ export async function runCodexTrimExecution({
901
+ threadId,
902
+ cwd,
903
+ rollbackTurns,
904
+ memoryText,
905
+ expectedTurns = null,
906
+ command = 'codex',
907
+ commandArgs = ['app-server', '--listen', 'stdio://'],
908
+ timeoutMs = 30_000,
909
+ requestTimeoutMs = 10_000,
910
+ postInjectReadAttempts = 5,
911
+ postInjectReadDelayMs = 100,
912
+ } = {}) {
913
+ assertNonEmptyString(threadId, 'runCodexTrimExecution: threadId');
914
+ assertNonEmptyString(cwd, 'runCodexTrimExecution: cwd');
915
+ assertNonEmptyString(command, 'runCodexTrimExecution: command');
916
+ assertNonEmptyString(memoryText, 'runCodexTrimExecution: memoryText');
917
+ if (!Number.isInteger(rollbackTurns) || rollbackTurns < 1) {
918
+ throw new Error('runCodexTrimExecution: rollbackTurns must be an integer >= 1');
919
+ }
920
+ assertOptionalTurnCount(expectedTurns, 'runCodexTrimExecution: expectedTurns');
921
+ if (!Array.isArray(commandArgs)) {
922
+ throw new Error('runCodexTrimExecution: commandArgs must be an array');
923
+ }
924
+ assertPositiveInteger(postInjectReadAttempts, 'runCodexTrimExecution: postInjectReadAttempts');
925
+ assertNonNegativeInteger(postInjectReadDelayMs, 'runCodexTrimExecution: postInjectReadDelayMs');
926
+
927
+ const client = startAppServerClient({
928
+ command,
929
+ args: commandArgs,
930
+ cwd,
931
+ timeoutMs,
932
+ requestTimeoutMs,
933
+ });
934
+
935
+ try {
936
+ await client.request(
937
+ buildInitializeRequest({
938
+ id: randomUUID(),
939
+ clientName: 'throughline-trim',
940
+ clientTitle: 'Throughline Trim',
941
+ }),
942
+ );
943
+ client.notify(buildInitializedNotification());
944
+
945
+ const beforeRead = await client.request(
946
+ buildThreadReadRequest({
947
+ id: randomUUID(),
948
+ threadId,
949
+ includeTurns: true,
950
+ }),
951
+ );
952
+ const resumed = await client.request(
953
+ buildThreadResumeRequest({
954
+ id: randomUUID(),
955
+ threadId,
956
+ cwd,
957
+ excludeTurns: false,
958
+ }),
959
+ );
960
+ const readTurns = countTurns(beforeRead);
961
+ const resumedTurns = countTurns(resumed);
962
+ const turnCountCheck = compareTurnCounts({
963
+ expectedTurns,
964
+ readTurns,
965
+ resumedTurns,
966
+ });
967
+ if (turnCountCheck.status === 'mismatch' || turnCountCheck.status === 'unknown') {
968
+ return {
969
+ status: 'refused',
970
+ reason: 'codex_rollout_app_server_turn_mismatch',
971
+ threadId,
972
+ rollbackSent: false,
973
+ injectSent: false,
974
+ injectedItems: 0,
975
+ readTurns,
976
+ resumedTurns,
977
+ rollbackRequestedTurns: rollbackTurns,
978
+ turnCountCheck,
979
+ notifications: [...new Set(client.notifications)],
980
+ stderr: client.stderr,
981
+ };
982
+ }
983
+ const rollback = await client.request(
984
+ buildThreadRollbackRequest({
985
+ id: randomUUID(),
986
+ threadId,
987
+ numTurns: rollbackTurns,
988
+ }),
989
+ );
990
+ const inject = await client.request(
991
+ buildThreadInjectItemsRequest({
992
+ id: randomUUID(),
993
+ threadId,
994
+ items: [buildDeveloperMessageItem(memoryText)],
995
+ }),
996
+ );
997
+ const rollbackResultTurns = countTurns(rollback);
998
+ const injectResultTurns = countTurns(inject);
999
+ const expectedPostInjectTurns = expectedPostInjectTurnCount({
1000
+ rollbackResultTurns,
1001
+ injectResultTurns,
1002
+ });
1003
+ const postInjectRead = await waitForThreadTurnCount({
1004
+ client,
1005
+ threadId,
1006
+ expectedTurns: expectedPostInjectTurns,
1007
+ attempts: postInjectReadAttempts,
1008
+ delayMs: postInjectReadDelayMs,
1009
+ });
1010
+
1011
+ return {
1012
+ status: 'executed',
1013
+ threadId,
1014
+ rollbackSent: true,
1015
+ injectSent: true,
1016
+ injectedItems: 1,
1017
+ readTurns,
1018
+ resumedTurns,
1019
+ rollbackRequestedTurns: rollbackTurns,
1020
+ rollbackResultTurns,
1021
+ injectResultTurns,
1022
+ afterTurns: postInjectRead.turns,
1023
+ postInjectReadAttempts: postInjectRead.attempts,
1024
+ postInjectVisibilityCheck: postInjectRead.visibilityCheck,
1025
+ turnCountCheck,
1026
+ notifications: [...new Set(client.notifications)],
1027
+ stderr: client.stderr,
1028
+ };
1029
+ } finally {
1030
+ await client.close();
1031
+ }
1032
+ }
1033
+
1034
+ export async function runCodexModelVisibilitySmoke({
1035
+ threadId,
1036
+ cwd,
1037
+ memoryText,
1038
+ marker,
1039
+ resumeAfterInject = false,
1040
+ command = 'codex',
1041
+ commandArgs = ['app-server', '--listen', 'stdio://'],
1042
+ timeoutMs = 60_000,
1043
+ requestTimeoutMs = 45_000,
1044
+ } = {}) {
1045
+ assertNonEmptyString(threadId, 'runCodexModelVisibilitySmoke: threadId');
1046
+ assertNonEmptyString(cwd, 'runCodexModelVisibilitySmoke: cwd');
1047
+ assertNonEmptyString(memoryText, 'runCodexModelVisibilitySmoke: memoryText');
1048
+ assertNonEmptyString(marker, 'runCodexModelVisibilitySmoke: marker');
1049
+ assertNonEmptyString(command, 'runCodexModelVisibilitySmoke: command');
1050
+ if (!Array.isArray(commandArgs)) {
1051
+ throw new Error('runCodexModelVisibilitySmoke: commandArgs must be an array');
1052
+ }
1053
+
1054
+ const client = startAppServerClient({
1055
+ command,
1056
+ args: commandArgs,
1057
+ cwd,
1058
+ timeoutMs,
1059
+ requestTimeoutMs,
1060
+ });
1061
+
1062
+ try {
1063
+ await client.request(
1064
+ buildInitializeRequest({
1065
+ id: randomUUID(),
1066
+ clientName: 'throughline-codex-visibility-smoke',
1067
+ clientTitle: 'Throughline Codex Visibility Smoke',
1068
+ }),
1069
+ );
1070
+ client.notify(buildInitializedNotification());
1071
+
1072
+ const beforeRead = await client.request(
1073
+ buildThreadReadRequest({
1074
+ id: randomUUID(),
1075
+ threadId,
1076
+ includeTurns: true,
1077
+ }),
1078
+ );
1079
+ const resumed = await client.request(
1080
+ buildThreadResumeRequest({
1081
+ id: randomUUID(),
1082
+ threadId,
1083
+ cwd,
1084
+ approvalPolicy: 'never',
1085
+ sandbox: 'read-only',
1086
+ excludeTurns: false,
1087
+ }),
1088
+ );
1089
+
1090
+ await client.request(
1091
+ buildThreadInjectItemsRequest({
1092
+ id: randomUUID(),
1093
+ threadId,
1094
+ items: [buildDeveloperMessageItem(memoryText)],
1095
+ }),
1096
+ );
1097
+ let postInjectResumedTurns = null;
1098
+ if (resumeAfterInject) {
1099
+ const postInjectResumed = await client.request(
1100
+ buildThreadResumeRequest({
1101
+ id: randomUUID(),
1102
+ threadId,
1103
+ cwd,
1104
+ approvalPolicy: 'never',
1105
+ sandbox: 'read-only',
1106
+ excludeTurns: false,
1107
+ }),
1108
+ );
1109
+ postInjectResumedTurns = countTurns(postInjectResumed);
1110
+ }
1111
+
1112
+ const prompt =
1113
+ `Throughline model-visible smoke. Reply with exactly this marker and nothing else: ${marker}`;
1114
+ await client.request(
1115
+ buildTurnStartRequest({
1116
+ id: randomUUID(),
1117
+ threadId,
1118
+ text: prompt,
1119
+ }),
1120
+ );
1121
+ const observedTurnEvent = await client.waitForNotification({
1122
+ predicate: (event) =>
1123
+ event.method === 'turn/completed' ||
1124
+ (event.method === 'item/agentMessage/delta' &&
1125
+ typeof event.params?.delta === 'string' &&
1126
+ event.params.delta.includes(marker)),
1127
+ timeoutMs: requestTimeoutMs,
1128
+ });
1129
+
1130
+ const agentText = collectAgentText(client.notificationEvents);
1131
+ const markerVisible = agentText.includes(marker);
1132
+
1133
+ return {
1134
+ status: markerVisible ? 'visible' : 'not-visible',
1135
+ reason: markerVisible
1136
+ ? 'marker_found_in_agent_message'
1137
+ : observedTurnEvent
1138
+ ? 'turn_completed_without_marker'
1139
+ : 'turn_notification_timeout',
1140
+ threadId,
1141
+ marker,
1142
+ readTurns: countTurns(beforeRead),
1143
+ resumedTurns: countTurns(resumed),
1144
+ postInjectResumedTurns,
1145
+ injectSent: true,
1146
+ resumeAfterInject: Boolean(resumeAfterInject),
1147
+ turnStartSent: true,
1148
+ agentText,
1149
+ notifications: [...new Set(client.notifications)],
1150
+ stderr: client.stderr,
1151
+ };
1152
+ } finally {
1153
+ await client.close();
1154
+ }
1155
+ }
1156
+
1157
+ export async function runCodexRollbackModelVisiblePrepare({
1158
+ threadId,
1159
+ cwd,
1160
+ marker,
1161
+ command = 'codex',
1162
+ commandArgs = ['app-server', '--listen', 'stdio://'],
1163
+ timeoutMs = 60_000,
1164
+ requestTimeoutMs = 45_000,
1165
+ } = {}) {
1166
+ assertNonEmptyString(threadId, 'runCodexRollbackModelVisiblePrepare: threadId');
1167
+ assertNonEmptyString(cwd, 'runCodexRollbackModelVisiblePrepare: cwd');
1168
+ assertNonEmptyString(marker, 'runCodexRollbackModelVisiblePrepare: marker');
1169
+ assertNonEmptyString(command, 'runCodexRollbackModelVisiblePrepare: command');
1170
+ if (!Array.isArray(commandArgs)) {
1171
+ throw new Error('runCodexRollbackModelVisiblePrepare: commandArgs must be an array');
1172
+ }
1173
+
1174
+ const client = startAppServerClient({
1175
+ command,
1176
+ args: commandArgs,
1177
+ cwd,
1178
+ timeoutMs,
1179
+ requestTimeoutMs,
1180
+ });
1181
+
1182
+ try {
1183
+ await initializeThroughlineAppServerClient(client, {
1184
+ clientName: 'throughline-codex-rollback-model-visible-smoke',
1185
+ clientTitle: 'Throughline Codex Rollback Model-Visible Smoke',
1186
+ });
1187
+
1188
+ const beforeRead = await client.request(
1189
+ buildThreadReadRequest({
1190
+ id: randomUUID(),
1191
+ threadId,
1192
+ includeTurns: true,
1193
+ }),
1194
+ );
1195
+ const resumed = await client.request(
1196
+ buildThreadResumeRequest({
1197
+ id: randomUUID(),
1198
+ threadId,
1199
+ cwd,
1200
+ }),
1201
+ );
1202
+ const setupPrompt = [
1203
+ 'Throughline controlled rollback model-visible setup.',
1204
+ `This user message contains this rollback visibility marker: ${marker}`,
1205
+ 'Reply exactly: TL_ROLLBACK_MODEL_VISIBLE_SETUP_DONE',
1206
+ ].join(' ');
1207
+ await client.request(
1208
+ buildTurnStartRequest({
1209
+ id: randomUUID(),
1210
+ threadId,
1211
+ text: setupPrompt,
1212
+ }),
1213
+ );
1214
+ const observedSetupEvent = await client.waitForNotification({
1215
+ predicate: (event) => event.method === 'turn/completed',
1216
+ timeoutMs: requestTimeoutMs,
1217
+ });
1218
+ const rollback = await client.request(
1219
+ buildThreadRollbackRequest({
1220
+ id: randomUUID(),
1221
+ threadId,
1222
+ numTurns: 1,
1223
+ }),
1224
+ );
1225
+ const afterRollbackRead = await client.request(
1226
+ buildThreadReadRequest({
1227
+ id: randomUUID(),
1228
+ threadId,
1229
+ includeTurns: true,
1230
+ }),
1231
+ );
1232
+
1233
+ return {
1234
+ status: 'prepared',
1235
+ reason: 'controlled_marker_turn_started_and_rolled_back',
1236
+ proofScope: 'controlled_same_thread_rollback_setup_only',
1237
+ restartSafe: false,
1238
+ threadId,
1239
+ marker,
1240
+ setupTurnStartSent: true,
1241
+ setupTurnCompletedObserved: Boolean(observedSetupEvent),
1242
+ rollbackSent: true,
1243
+ rollbackRequestedTurns: 1,
1244
+ beforeTurns: countTurns(beforeRead),
1245
+ resumedTurns: countTurns(resumed),
1246
+ rollbackResultTurns: countTurns(rollback),
1247
+ afterRollbackTurns: countTurns(afterRollbackRead),
1248
+ verifyPromptIncludesMarker: false,
1249
+ notifications: [...new Set(client.notifications)],
1250
+ stderr: client.stderr,
1251
+ };
1252
+ } finally {
1253
+ await client.close();
1254
+ }
1255
+ }
1256
+
1257
+ export async function runCodexRollbackModelVisibleVerify({
1258
+ threadId,
1259
+ cwd,
1260
+ marker,
1261
+ markerPrefix = 'TL_ROLLBACK_MODEL_VISIBLE_',
1262
+ notVisibleToken = 'TL_ROLLBACK_MODEL_VISIBLE_NOT_VISIBLE',
1263
+ command = 'codex',
1264
+ commandArgs = ['app-server', '--listen', 'stdio://'],
1265
+ timeoutMs = 60_000,
1266
+ requestTimeoutMs = 45_000,
1267
+ } = {}) {
1268
+ assertNonEmptyString(threadId, 'runCodexRollbackModelVisibleVerify: threadId');
1269
+ assertNonEmptyString(cwd, 'runCodexRollbackModelVisibleVerify: cwd');
1270
+ assertNonEmptyString(marker, 'runCodexRollbackModelVisibleVerify: marker');
1271
+ assertNonEmptyString(markerPrefix, 'runCodexRollbackModelVisibleVerify: markerPrefix');
1272
+ assertNonEmptyString(notVisibleToken, 'runCodexRollbackModelVisibleVerify: notVisibleToken');
1273
+ assertNonEmptyString(command, 'runCodexRollbackModelVisibleVerify: command');
1274
+ if (!Array.isArray(commandArgs)) {
1275
+ throw new Error('runCodexRollbackModelVisibleVerify: commandArgs must be an array');
1276
+ }
1277
+ if (marker.includes(notVisibleToken)) {
1278
+ throw new Error('runCodexRollbackModelVisibleVerify: marker must not contain notVisibleToken');
1279
+ }
1280
+
1281
+ const client = startAppServerClient({
1282
+ command,
1283
+ args: commandArgs,
1284
+ cwd,
1285
+ timeoutMs,
1286
+ requestTimeoutMs,
1287
+ });
1288
+
1289
+ try {
1290
+ await initializeThroughlineAppServerClient(client, {
1291
+ clientName: 'throughline-codex-rollback-model-visible-smoke',
1292
+ clientTitle: 'Throughline Codex Rollback Model-Visible Smoke',
1293
+ });
1294
+
1295
+ const beforeRead = await client.request(
1296
+ buildThreadReadRequest({
1297
+ id: randomUUID(),
1298
+ threadId,
1299
+ includeTurns: true,
1300
+ }),
1301
+ );
1302
+ const resumed = await client.request(
1303
+ buildThreadResumeRequest({
1304
+ id: randomUUID(),
1305
+ threadId,
1306
+ cwd,
1307
+ }),
1308
+ );
1309
+ const verifyPrompt = [
1310
+ 'Throughline controlled rollback model-visible verification.',
1311
+ `If your visible previous conversation context contains a token beginning with ${markerPrefix}, reply with exactly that complete token.`,
1312
+ `If no such token is visible, reply exactly: ${notVisibleToken}.`,
1313
+ 'Do not invent or guess a token.',
1314
+ ].join(' ');
1315
+ await client.request(
1316
+ buildTurnStartRequest({
1317
+ id: randomUUID(),
1318
+ threadId,
1319
+ text: verifyPrompt,
1320
+ }),
1321
+ );
1322
+ const observedTurnEvent = await client.waitForNotification({
1323
+ predicate: (event) =>
1324
+ event.method === 'turn/completed' ||
1325
+ (event.method === 'item/agentMessage/delta' &&
1326
+ typeof event.params?.delta === 'string' &&
1327
+ (event.params.delta.includes(marker) || event.params.delta.includes(notVisibleToken))),
1328
+ timeoutMs: requestTimeoutMs,
1329
+ });
1330
+ const agentText = collectAgentText(client.notificationEvents);
1331
+ const observedMarkers = extractRollbackModelVisibleMarkers(agentText, markerPrefix);
1332
+ const markerVisible = observedMarkers.includes(marker);
1333
+ const notVisible = agentText.includes(notVisibleToken);
1334
+ const status = markerVisible ? 'reproduced' : notVisible ? 'not-reproduced' : 'inconclusive';
1335
+
1336
+ return {
1337
+ status,
1338
+ reason: markerVisible
1339
+ ? 'rolled_back_marker_returned_by_model'
1340
+ : notVisible
1341
+ ? 'model_reported_rolled_back_marker_not_visible'
1342
+ : observedTurnEvent
1343
+ ? 'turn_completed_without_expected_marker_or_not_visible_token'
1344
+ : 'turn_notification_timeout',
1345
+ proofScope: 'controlled_same_thread_model_visible_verification',
1346
+ restartSafe: false,
1347
+ threadId,
1348
+ marker,
1349
+ markerPrefix,
1350
+ promptIncludesMarker: verifyPrompt.includes(marker),
1351
+ rolledBackMarkerModelVisible:
1352
+ status === 'reproduced' ? true : status === 'not-reproduced' ? false : null,
1353
+ modelReportedNotVisible: notVisible,
1354
+ turnStartSent: true,
1355
+ readTurns: countTurns(beforeRead),
1356
+ resumedTurns: countTurns(resumed),
1357
+ observedMarkers,
1358
+ notVisibleToken,
1359
+ agentText,
1360
+ notifications: [...new Set(client.notifications)],
1361
+ stderr: client.stderr,
1362
+ };
1363
+ } finally {
1364
+ await client.close();
1365
+ }
1366
+ }
1367
+
1368
+ function initializeThroughlineAppServerClient(client, { clientName, clientTitle }) {
1369
+ return client
1370
+ .request(
1371
+ buildInitializeRequest({
1372
+ id: randomUUID(),
1373
+ clientName,
1374
+ clientTitle,
1375
+ }),
1376
+ )
1377
+ .then(() => {
1378
+ client.notify(buildInitializedNotification());
1379
+ });
1380
+ }
1381
+
1382
+ function extractRollbackModelVisibleMarkers(text, markerPrefix) {
1383
+ if (typeof text !== 'string' || !markerPrefix) return [];
1384
+ const escaped = markerPrefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
1385
+ const pattern = new RegExp(`${escaped}[A-Za-z0-9_-]+`, 'g');
1386
+ return [...new Set(text.match(pattern) ?? [])];
1387
+ }
1388
+
1389
+ async function waitForThreadTurnCount({ client, threadId, expectedTurns, attempts, delayMs }) {
1390
+ let lastTurns = null;
1391
+ for (let attempt = 1; attempt <= attempts; attempt++) {
1392
+ if (attempt > 1 && delayMs > 0) {
1393
+ await sleep(delayMs);
1394
+ }
1395
+ const read = await client.request(
1396
+ buildThreadReadRequest({
1397
+ id: randomUUID(),
1398
+ threadId,
1399
+ includeTurns: true,
1400
+ }),
1401
+ );
1402
+ lastTurns = countTurns(read);
1403
+ if (expectedTurns === null || expectedTurns === undefined) {
1404
+ return {
1405
+ turns: lastTurns,
1406
+ attempts: attempt,
1407
+ visibilityCheck: {
1408
+ status: 'unchecked',
1409
+ reason: 'expected_post_inject_turn_count_unavailable',
1410
+ expectedTurns,
1411
+ actualTurns: lastTurns,
1412
+ },
1413
+ };
1414
+ }
1415
+ if (lastTurns === expectedTurns) {
1416
+ return {
1417
+ turns: lastTurns,
1418
+ attempts: attempt,
1419
+ visibilityCheck: {
1420
+ status: 'match',
1421
+ reason: 'post_inject_turn_count_visible',
1422
+ expectedTurns,
1423
+ actualTurns: lastTurns,
1424
+ },
1425
+ };
1426
+ }
1427
+ }
1428
+
1429
+ return {
1430
+ turns: lastTurns,
1431
+ attempts,
1432
+ visibilityCheck: {
1433
+ status: 'timeout',
1434
+ reason: 'post_inject_turn_count_not_visible_after_reads',
1435
+ expectedTurns,
1436
+ actualTurns: lastTurns,
1437
+ },
1438
+ };
1439
+ }
1440
+
1441
+ function expectedPostInjectTurnCount({ rollbackResultTurns, injectResultTurns }) {
1442
+ if (Number.isInteger(injectResultTurns)) return injectResultTurns;
1443
+ if (Number.isInteger(rollbackResultTurns)) return rollbackResultTurns;
1444
+ return null;
1445
+ }
1446
+
1447
+ function sleep(ms) {
1448
+ return new Promise((resolve) => setTimeout(resolve, ms));
1449
+ }
1450
+
1451
+ function startAppServerClient({ command, args, cwd, timeoutMs, requestTimeoutMs }) {
1452
+ const child = spawn(command, args, {
1453
+ cwd,
1454
+ stdio: ['pipe', 'pipe', 'pipe'],
1455
+ });
1456
+ let stdoutBuffer = '';
1457
+ let stderr = '';
1458
+ let closed = false;
1459
+ let failure = null;
1460
+ const pending = new Map();
1461
+ const notifications = [];
1462
+ const notificationEvents = [];
1463
+ const notificationWaiters = [];
1464
+
1465
+ const overallTimer = setTimeout(() => {
1466
+ if (!closed) {
1467
+ child.kill('SIGTERM');
1468
+ }
1469
+ }, timeoutMs);
1470
+
1471
+ child.stdout.on('data', (chunk) => {
1472
+ stdoutBuffer += chunk.toString('utf8');
1473
+ let newlineIndex;
1474
+ while ((newlineIndex = stdoutBuffer.indexOf('\n')) >= 0) {
1475
+ const line = stdoutBuffer.slice(0, newlineIndex);
1476
+ stdoutBuffer = stdoutBuffer.slice(newlineIndex + 1);
1477
+ if (!line.trim()) continue;
1478
+ let message;
1479
+ try {
1480
+ message = parseAppServerLine(line);
1481
+ } catch {
1482
+ continue;
1483
+ }
1484
+
1485
+ if ((message.kind === 'response' || message.kind === 'error') && pending.has(message.id)) {
1486
+ const pendingRequest = pending.get(message.id);
1487
+ pending.delete(message.id);
1488
+ pendingRequest.finish(message);
1489
+ } else if (message.kind === 'notification') {
1490
+ const event = {
1491
+ method: message.method,
1492
+ params: message.params,
1493
+ };
1494
+ notifications.push(message.method);
1495
+ notificationEvents.push(event);
1496
+ resolveNotificationWaiters(event);
1497
+ }
1498
+ }
1499
+ });
1500
+
1501
+ child.stderr.on('data', (chunk) => {
1502
+ stderr += chunk.toString('utf8');
1503
+ });
1504
+
1505
+ child.on('error', (err) => {
1506
+ failure = err instanceof Error ? err : new Error(String(err));
1507
+ closed = true;
1508
+ clearTimeout(overallTimer);
1509
+ rejectPending(`codex app-server failed to start: ${failure.message}`);
1510
+ });
1511
+
1512
+ child.on('exit', (code, signal) => {
1513
+ closed = true;
1514
+ clearTimeout(overallTimer);
1515
+ rejectPending(`codex app-server exited before response: code=${code} signal=${signal}`);
1516
+ });
1517
+
1518
+ return {
1519
+ notifications,
1520
+ notificationEvents,
1521
+ get stderr() {
1522
+ return summarizeAppServerStderr(stderr);
1523
+ },
1524
+ request(message) {
1525
+ if (!isRequestId(message.id)) {
1526
+ throw new Error('app-server request message requires an id');
1527
+ }
1528
+ if (failure) {
1529
+ return Promise.reject(new Error(`codex app-server is unavailable: ${failure.message}`));
1530
+ }
1531
+ return new Promise((resolve, reject) => {
1532
+ const timer = setTimeout(() => {
1533
+ if (pending.has(message.id)) {
1534
+ pending.delete(message.id);
1535
+ reject(new Error(`timeout waiting for app-server response to ${message.method}`));
1536
+ }
1537
+ }, requestTimeoutMs);
1538
+ pending.set(message.id, {
1539
+ reject,
1540
+ finish(response) {
1541
+ clearTimeout(timer);
1542
+ if (response.kind === 'error') {
1543
+ reject(new Error(`${message.method}: ${JSON.stringify(response.error)}`));
1544
+ } else {
1545
+ resolve(response.result);
1546
+ }
1547
+ },
1548
+ });
1549
+ try {
1550
+ child.stdin.write(encodeAppServerMessage(message));
1551
+ } catch (err) {
1552
+ clearTimeout(timer);
1553
+ pending.delete(message.id);
1554
+ const msg = err instanceof Error ? err.message : 'unknown';
1555
+ reject(new Error(`failed to write app-server request ${message.method}: ${msg}`));
1556
+ }
1557
+ });
1558
+ },
1559
+ notify(message) {
1560
+ child.stdin.write(encodeAppServerMessage(message));
1561
+ },
1562
+ waitForNotification({ predicate, timeoutMs }) {
1563
+ if (typeof predicate !== 'function') {
1564
+ throw new Error('waitForNotification: predicate must be a function');
1565
+ }
1566
+ const existing = notificationEvents.find(predicate);
1567
+ if (existing) return Promise.resolve(existing);
1568
+
1569
+ return new Promise((resolve) => {
1570
+ const timer = setTimeout(() => {
1571
+ const index = notificationWaiters.findIndex((waiter) => waiter.resolve === resolve);
1572
+ if (index >= 0) notificationWaiters.splice(index, 1);
1573
+ resolve(null);
1574
+ }, timeoutMs);
1575
+ notificationWaiters.push({
1576
+ predicate,
1577
+ resolve(event) {
1578
+ clearTimeout(timer);
1579
+ resolve(event);
1580
+ },
1581
+ });
1582
+ });
1583
+ },
1584
+ close() {
1585
+ clearTimeout(overallTimer);
1586
+ child.kill('SIGTERM');
1587
+ child.stdin.destroy();
1588
+ if (closed) return Promise.resolve();
1589
+ return new Promise((resolve) => {
1590
+ child.once('exit', resolve);
1591
+ setTimeout(resolve, 1_000);
1592
+ });
1593
+ },
1594
+ };
1595
+
1596
+ function rejectPending(reason) {
1597
+ for (const [id, pendingRequest] of pending) {
1598
+ pending.delete(id);
1599
+ pendingRequest.reject(new Error(`${reason}; request=${id}`));
1600
+ }
1601
+ }
1602
+
1603
+ function resolveNotificationWaiters(event) {
1604
+ for (let i = notificationWaiters.length - 1; i >= 0; i--) {
1605
+ const waiter = notificationWaiters[i];
1606
+ if (!waiter.predicate(event)) continue;
1607
+ notificationWaiters.splice(i, 1);
1608
+ waiter.resolve(event);
1609
+ }
1610
+ }
1611
+ }
1612
+
1613
+ function collectAgentText(notificationEvents) {
1614
+ return notificationEvents
1615
+ .filter((event) => event.method === 'item/agentMessage/delta')
1616
+ .map((event) => (typeof event.params?.delta === 'string' ? event.params.delta : ''))
1617
+ .join('');
1618
+ }
1619
+
1620
+ function countTurns(result) {
1621
+ const thread = isRecord(result) && isRecord(result.thread) ? result.thread : result;
1622
+ return isRecord(thread) && Array.isArray(thread.turns) ? thread.turns.length : null;
1623
+ }
1624
+
1625
+ export function compareTurnCounts({ expectedTurns = null, readTurns = null, resumedTurns = null } = {}) {
1626
+ assertOptionalTurnCount(expectedTurns, 'compareTurnCounts: expectedTurns');
1627
+ const counts = { expectedTurns, readTurns, resumedTurns };
1628
+ if (expectedTurns === null || expectedTurns === undefined) {
1629
+ return {
1630
+ status: 'unchecked',
1631
+ reason: 'expected_turns_not_available',
1632
+ ...counts,
1633
+ };
1634
+ }
1635
+
1636
+ if (!Number.isInteger(readTurns) || !Number.isInteger(resumedTurns)) {
1637
+ return {
1638
+ status: 'unknown',
1639
+ reason: 'app_server_turn_count_unavailable',
1640
+ ...counts,
1641
+ };
1642
+ }
1643
+
1644
+ if (readTurns === expectedTurns && resumedTurns === expectedTurns) {
1645
+ return {
1646
+ status: 'match',
1647
+ reason: 'rollout_and_app_server_turn_counts_match',
1648
+ ...counts,
1649
+ };
1650
+ }
1651
+
1652
+ return {
1653
+ status: 'mismatch',
1654
+ reason: 'rollout_and_app_server_turn_counts_differ',
1655
+ ...counts,
1656
+ };
1657
+ }
1658
+
1659
+ function compareRestoreTurnCounts({
1660
+ expectedTurns = null,
1661
+ readTurns = null,
1662
+ resumedTurns = null,
1663
+ turnsListTurns = null,
1664
+ turnsListComplete = true,
1665
+ } = {}) {
1666
+ assertOptionalTurnCount(expectedTurns, 'compareRestoreTurnCounts: expectedTurns');
1667
+ const counts = { expectedTurns, readTurns, resumedTurns, turnsListTurns };
1668
+ if (expectedTurns === null || expectedTurns === undefined) {
1669
+ return {
1670
+ status: 'unchecked',
1671
+ reason: 'expected_turns_not_available',
1672
+ ...counts,
1673
+ turnsListComplete,
1674
+ };
1675
+ }
1676
+
1677
+ if (
1678
+ !Number.isInteger(readTurns) ||
1679
+ !Number.isInteger(resumedTurns) ||
1680
+ !Number.isInteger(turnsListTurns)
1681
+ ) {
1682
+ return {
1683
+ status: 'unknown',
1684
+ reason: 'app_server_turn_count_unavailable',
1685
+ ...counts,
1686
+ turnsListComplete,
1687
+ };
1688
+ }
1689
+
1690
+ if (turnsListComplete !== true) {
1691
+ return {
1692
+ status: 'unknown',
1693
+ reason: 'app_server_turns_list_incomplete',
1694
+ ...counts,
1695
+ turnsListComplete,
1696
+ };
1697
+ }
1698
+
1699
+ if (readTurns === expectedTurns && resumedTurns === expectedTurns && turnsListTurns === expectedTurns) {
1700
+ return {
1701
+ status: 'match',
1702
+ reason: 'rollout_and_app_server_restore_counts_match',
1703
+ ...counts,
1704
+ turnsListComplete,
1705
+ };
1706
+ }
1707
+
1708
+ return {
1709
+ status: 'mismatch',
1710
+ reason: 'rollout_and_app_server_restore_counts_differ',
1711
+ ...counts,
1712
+ turnsListComplete,
1713
+ };
1714
+ }
1715
+
1716
+ export function summarizeAppServerStderr(stderr) {
1717
+ if (typeof stderr !== 'string' || stderr.length === 0) return '';
1718
+
1719
+ const lines = stderr.split('\n');
1720
+ const out = [];
1721
+ const suppressedByTurn = new Map();
1722
+ const unknownTurnWarning =
1723
+ /WARN codex_app_server_protocol::protocol::thread_history: dropping turn-scoped item for unknown turn id `([^`]+)` item_id=/;
1724
+
1725
+ for (const line of lines) {
1726
+ if (line === '') continue;
1727
+ const match = line.match(unknownTurnWarning);
1728
+ if (!match) {
1729
+ out.push(line);
1730
+ continue;
1731
+ }
1732
+
1733
+ const turnId = match[1];
1734
+ const current = suppressedByTurn.get(turnId) ?? { seen: 0, suppressed: 0 };
1735
+ if (current.seen === 0) {
1736
+ out.push(line);
1737
+ } else {
1738
+ current.suppressed++;
1739
+ }
1740
+ current.seen++;
1741
+ suppressedByTurn.set(turnId, current);
1742
+ }
1743
+
1744
+ for (const [turnId, { suppressed }] of suppressedByTurn.entries()) {
1745
+ if (suppressed > 0) {
1746
+ out.push(
1747
+ `[throughline] suppressed ${suppressed} repeated Codex app-server unknown-turn item warnings for turn ${turnId}`,
1748
+ );
1749
+ }
1750
+ }
1751
+
1752
+ const summarized = out.length === 0 ? '' : `${out.join('\n')}\n`;
1753
+ if (summarized.length <= MAX_APP_SERVER_STDERR_CHARS) return summarized;
1754
+
1755
+ const omitted = summarized.length - MAX_APP_SERVER_STDERR_CHARS;
1756
+ return `${summarized.slice(0, MAX_APP_SERVER_STDERR_CHARS)}\n[throughline] truncated ${omitted} chars of Codex app-server stderr\n`;
1757
+ }
1758
+
1759
+ function compactNullish(value) {
1760
+ return Object.fromEntries(
1761
+ Object.entries(value).filter(([, entry]) => entry !== null && entry !== undefined),
1762
+ );
1763
+ }
1764
+
1765
+ function assertRequestId(id, caller) {
1766
+ if (!isRequestId(id)) {
1767
+ throw new Error(`${caller}: id must be a string or integer`);
1768
+ }
1769
+ }
1770
+
1771
+ function assertNonEmptyString(value, label) {
1772
+ if (typeof value !== 'string' || value.length === 0) {
1773
+ throw new Error(`${label} must be a non-empty string`);
1774
+ }
1775
+ }
1776
+
1777
+ function assertOptionalTurnCount(value, label) {
1778
+ if (value === null || value === undefined) return;
1779
+ if (!Number.isInteger(value) || value < 0) {
1780
+ throw new Error(`${label} must be a non-negative integer when provided`);
1781
+ }
1782
+ }
1783
+
1784
+ function assertRestoreTextNeedles(value, label) {
1785
+ if (!Array.isArray(value)) {
1786
+ throw new Error(`${label} must be an array`);
1787
+ }
1788
+ for (const [index, needle] of value.entries()) {
1789
+ if (!isRecord(needle)) {
1790
+ throw new Error(`${label}[${index}] must be an object`);
1791
+ }
1792
+ assertNonEmptyString(needle.id, `${label}[${index}].id`);
1793
+ assertNonEmptyString(needle.value, `${label}[${index}].value`);
1794
+ assertNonEmptyString(needle.textPreview, `${label}[${index}].textPreview`);
1795
+ }
1796
+ }
1797
+
1798
+ function assertPositiveInteger(value, label) {
1799
+ if (!Number.isInteger(value) || value < 1) {
1800
+ throw new Error(`${label} must be an integer >= 1`);
1801
+ }
1802
+ }
1803
+
1804
+ function assertNonNegativeInteger(value, label) {
1805
+ if (!Number.isInteger(value) || value < 0) {
1806
+ throw new Error(`${label} must be a non-negative integer`);
1807
+ }
1808
+ }
1809
+
1810
+ function isRequestId(value) {
1811
+ return typeof value === 'string' || (typeof value === 'number' && Number.isInteger(value));
1812
+ }
1813
+
1814
+ function isRecord(value) {
1815
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
1816
+ }