remote-codex 0.11.51 → 0.11.53

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.
@@ -10,13 +10,13 @@
10
10
  <link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
11
11
  <link rel="manifest" href="/site.webmanifest" />
12
12
  <title>Remote Codex</title>
13
- <script type="module" crossorigin src="/assets/index-7LiZQ3aJ.js"></script>
13
+ <script type="module" crossorigin src="/assets/index-Bnury8uP.js"></script>
14
14
  <link rel="modulepreload" crossorigin href="/assets/react-vendor-Dfg_6BLf.js">
15
15
  <link rel="modulepreload" crossorigin href="/assets/ui-vendor-CuR8GHb0.js">
16
16
  <link rel="modulepreload" crossorigin href="/assets/graph-vendor-DVQUpZ8C.js">
17
17
  <link rel="modulepreload" crossorigin href="/assets/terminal-vendor-C5bTa-Ka.js">
18
18
  <link rel="modulepreload" crossorigin href="/assets/markdown-vendor-BG6PurxI.js">
19
- <link rel="modulepreload" crossorigin href="/assets/thread-ui-BbtcUwps.js">
19
+ <link rel="modulepreload" crossorigin href="/assets/thread-ui-C5pxOmDJ.js">
20
20
  <link rel="stylesheet" crossorigin href="/assets/graph-vendor-C5ap-Sga.css">
21
21
  <link rel="stylesheet" crossorigin href="/assets/terminal-vendor-Beg8tuEN.css">
22
22
  <link rel="stylesheet" crossorigin href="/assets/index-Bg8FdhS1.css">
@@ -238,7 +238,7 @@ async function runRelaySupervisor() {
238
238
  return;
239
239
  }
240
240
  if (action === 'start' && shouldStartRelaySupervisorInTmux()) {
241
- startRelaySupervisorTmux();
241
+ await startRelaySupervisorTmux();
242
242
  return;
243
243
  }
244
244
  runRelaySupervisorForeground();
@@ -677,7 +677,7 @@ function shouldStartRelaySupervisorInTmux() {
677
677
  return commandExists('tmux');
678
678
  }
679
679
 
680
- function startRelaySupervisorTmux() {
680
+ async function startRelaySupervisorTmux() {
681
681
  persistRelaySupervisorRuntimeConfig();
682
682
  if (tmuxSessionExists(relaySupervisorTmuxSession)) {
683
683
  console.log(`remote-codex relay-supervisor is already running in tmux session: ${relaySupervisorTmuxSession}`);
@@ -702,6 +702,17 @@ function startRelaySupervisorTmux() {
702
702
  return;
703
703
  }
704
704
 
705
+ const startupDeadline = Date.now() + 1_000;
706
+ while (Date.now() < startupDeadline) {
707
+ if (!tmuxSessionExists(relaySupervisorTmuxSession)) {
708
+ console.error('relay-supervisor exited immediately after tmux launched it.');
709
+ console.error('Restarting in foreground to show the startup error.');
710
+ runRelaySupervisorForeground();
711
+ return;
712
+ }
713
+ await sleep(100);
714
+ }
715
+
705
716
  console.log(`Started remote-codex relay-supervisor in tmux session: ${relaySupervisorTmuxSession}`);
706
717
  printRelaySupervisorTmuxCommands();
707
718
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "remote-codex",
3
- "version": "0.11.51",
3
+ "version": "0.11.53",
4
4
  "description": "Local web supervisor for Codex workspaces and threads.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -471,7 +471,7 @@ describe('AcpRuntimeAdapter', () => {
471
471
  env: {
472
472
  REMOTE_CODEX_FAKE_ACP_AGENT_KIND: 'codex',
473
473
  REMOTE_CODEX_FAKE_ACP_SKIP_PERMISSION: '1',
474
- REMOTE_CODEX_FAKE_ACP_STREAM_DELAY_MS: '100',
474
+ REMOTE_CODEX_FAKE_ACP_STREAM_DELAY_MS: '1000',
475
475
  },
476
476
  startupTimeoutMs: 5_000,
477
477
  });
@@ -500,10 +500,23 @@ describe('AcpRuntimeAdapter', () => {
500
500
  });
501
501
  },
502
502
  );
503
+ const partialStarted = new Promise<void>((resolve, reject) => {
504
+ const timer = setTimeout(() => reject(new Error('Steerable fixture partial timed out.')), 10_000);
505
+ const onEvent = (event: AgentRuntimeEvent) => {
506
+ if (event.type !== 'output.delta' || !event.delta.includes('FAKE_ACP_PARTIAL')) {
507
+ return;
508
+ }
509
+ clearTimeout(timer);
510
+ adapter.off('event', onEvent);
511
+ resolve();
512
+ };
513
+ adapter.on('event', onEvent);
514
+ });
503
515
  const running = await adapter.startTurn({
504
516
  providerSessionId: session.providerSessionId,
505
517
  prompt: 'Start a steerable fixture turn.',
506
518
  });
519
+ await partialStarted;
507
520
  await expect(adapter.sendInput({
508
521
  providerSessionId: session.providerSessionId,
509
522
  providerTurnId: running.providerTurnId,
@@ -315,7 +315,7 @@ describe('CodexAppServerManager', () => {
315
315
 
316
316
  const manager = new CodexAppServerManager({
317
317
  command: process.execPath,
318
- startupTimeoutMs: 1000,
318
+ startupTimeoutMs: 5_000,
319
319
  clientInfo: {
320
320
  name: 'test',
321
321
  title: 'test',
@@ -388,7 +388,7 @@ describe('CodexAppServerManager', () => {
388
388
  ]);
389
389
 
390
390
  await manager.stop();
391
- });
391
+ }, 15_000);
392
392
 
393
393
  it('does not let a stale child exit close the replacement app-server client during restart', async () => {
394
394
  const firstChild = new ScriptedChild({
@@ -102,6 +102,11 @@ describe('codex history item persistence policy', () => {
102
102
  text: 'done',
103
103
  completedAt: '2026-06-13T22:00:08.456Z',
104
104
  },
105
+ {
106
+ id: 'msg_019d70d5-9dc8-7000-8000-000000000000',
107
+ type: 'agentMessage',
108
+ text: 'inferred from id',
109
+ },
105
110
  ],
106
111
  });
107
112
 
@@ -114,6 +119,10 @@ describe('codex history item persistence policy', () => {
114
119
  id: 'agent-1',
115
120
  createdAt: '2026-06-13T22:00:08.456Z',
116
121
  },
122
+ {
123
+ id: 'msg_019d70d5-9dc8-7000-8000-000000000000',
124
+ createdAt: '2026-04-09T06:02:21.000Z',
125
+ },
117
126
  ]);
118
127
  });
119
128
 
@@ -25,17 +25,29 @@ interface WebSearchSourceRecord {
25
25
  }
26
26
 
27
27
  export function parseUuidV7Timestamp(id: string): string | null {
28
- const normalized = id.replace(/-/g, '');
29
- if (!/^[0-9a-f]{32}$/i.test(normalized) || normalized[12]?.toLowerCase() !== '7') {
30
- return null;
31
- }
28
+ const candidates = [
29
+ id,
30
+ ...(id.match(
31
+ /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|[0-9a-f]{32}/gi,
32
+ ) ?? []),
33
+ ];
32
34
 
33
- const millis = Number.parseInt(normalized.slice(0, 12), 16);
34
- if (!Number.isFinite(millis)) {
35
- return null;
35
+ for (const candidate of candidates) {
36
+ const normalized = candidate.replace(/-/g, '');
37
+ if (
38
+ !/^[0-9a-f]{32}$/i.test(normalized) ||
39
+ normalized[12]?.toLowerCase() !== '7'
40
+ ) {
41
+ continue;
42
+ }
43
+
44
+ const millis = Number.parseInt(normalized.slice(0, 12), 16);
45
+ if (Number.isFinite(millis)) {
46
+ return new Date(millis).toISOString();
47
+ }
36
48
  }
37
49
 
38
- return new Date(millis).toISOString();
50
+ return null;
39
51
  }
40
52
 
41
53
  function isRecord(value: unknown): value is Record<string, unknown> {
@@ -0,0 +1,455 @@
1
+ import fs from 'node:fs/promises';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+
5
+ import Database from 'better-sqlite3';
6
+ import { afterEach, describe, expect, it } from 'vitest';
7
+
8
+ import { LocalCodexSessionStore } from './local-session-store';
9
+
10
+ describe('LocalCodexSessionStore', () => {
11
+ const tempDirectories: string[] = [];
12
+
13
+ afterEach(async () => {
14
+ await Promise.all(
15
+ tempDirectories.splice(0).map((directory) =>
16
+ fs.rm(directory, { recursive: true, force: true }),
17
+ ),
18
+ );
19
+ });
20
+
21
+ it('recovers current response-item rollouts when the state database is corrupt', async () => {
22
+ const codexHome = await fs.mkdtemp(
23
+ path.join(os.tmpdir(), 'remote-codex-local-session-'),
24
+ );
25
+ tempDirectories.push(codexHome);
26
+ const sessionId = '01a059bf-c303-79b1-8802-f922d3a81968';
27
+ const workspacePath = path.join(codexHome, 'workspace');
28
+ const sessionsDirectory = path.join(codexHome, 'sessions', '2026', '08', '31');
29
+ await fs.mkdir(sessionsDirectory, { recursive: true });
30
+ await fs.writeFile(
31
+ path.join(codexHome, 'state_5.sqlite'),
32
+ 'this is not a valid sqlite database',
33
+ );
34
+ await fs.writeFile(
35
+ path.join(sessionsDirectory, `rollout-${sessionId}.jsonl`),
36
+ [
37
+ {
38
+ timestamp: '2026-08-31T21:36:17.000Z',
39
+ type: 'session_meta',
40
+ payload: { id: sessionId, cwd: workspacePath },
41
+ },
42
+ {
43
+ timestamp: '2026-08-31T21:36:18.000Z',
44
+ type: 'event_msg',
45
+ payload: { type: 'task_started', turn_id: 'turn-1' },
46
+ },
47
+ {
48
+ timestamp: '2026-08-31T21:36:19.000Z',
49
+ type: 'response_item',
50
+ payload: {
51
+ id: 'message-user-1',
52
+ type: 'message',
53
+ role: 'user',
54
+ content: [
55
+ { type: 'input_text', text: 'Recover this session.' },
56
+ { type: 'input_image', image_url: 'data:image/png;base64,ignored' },
57
+ ],
58
+ },
59
+ },
60
+ {
61
+ timestamp: '2026-08-31T21:36:24.000Z',
62
+ type: 'response_item',
63
+ payload: {
64
+ id: 'message-agent-1',
65
+ type: 'message',
66
+ role: 'assistant',
67
+ phase: 'final_answer',
68
+ content: [{ type: 'output_text', text: 'Recovered.' }],
69
+ },
70
+ },
71
+ {
72
+ timestamp: '2026-08-31T21:36:25.000Z',
73
+ type: 'event_msg',
74
+ payload: { type: 'task_complete', turn_id: 'turn-1' },
75
+ },
76
+ ]
77
+ .map((entry) => JSON.stringify(entry))
78
+ .join('\n'),
79
+ );
80
+
81
+ const session = await new LocalCodexSessionStore(codexHome).findSession(
82
+ sessionId,
83
+ );
84
+
85
+ expect(session).toMatchObject({
86
+ sessionId,
87
+ cwd: workspacePath,
88
+ title: 'Recover this se...',
89
+ turns: [
90
+ {
91
+ id: 'turn-1',
92
+ startedAt: '2026-08-31T21:36:18.000Z',
93
+ status: 'completed',
94
+ items: [
95
+ {
96
+ id: 'message-user-1',
97
+ kind: 'userMessage',
98
+ text: 'Recover this session.',
99
+ createdAt: '2026-08-31T21:36:19.000Z',
100
+ },
101
+ {
102
+ id: 'message-agent-1',
103
+ kind: 'agentMessage',
104
+ text: 'Recovered.',
105
+ createdAt: '2026-08-31T21:36:24.000Z',
106
+ },
107
+ ],
108
+ },
109
+ ],
110
+ });
111
+ });
112
+
113
+ it('parses legacy and response-item turns from a session that spans upgrades', async () => {
114
+ const codexHome = await fs.mkdtemp(
115
+ path.join(os.tmpdir(), 'remote-codex-mixed-session-'),
116
+ );
117
+ tempDirectories.push(codexHome);
118
+ const sessionId = '01a059bf-c303-79b1-8802-f922d3a81969';
119
+ const workspacePath = path.join(codexHome, 'workspace');
120
+ const sessionsDirectory = path.join(codexHome, 'sessions');
121
+ await fs.mkdir(sessionsDirectory, { recursive: true });
122
+ await fs.writeFile(
123
+ path.join(sessionsDirectory, `rollout-${sessionId}.jsonl`),
124
+ [
125
+ {
126
+ timestamp: '2026-08-30T00:00:00.000Z',
127
+ type: 'session_meta',
128
+ payload: { id: sessionId, cwd: workspacePath },
129
+ },
130
+ {
131
+ timestamp: '2026-08-30T00:00:01.000Z',
132
+ type: 'event_msg',
133
+ payload: { type: 'task_started', turn_id: 'legacy-turn' },
134
+ },
135
+ {
136
+ timestamp: '2026-08-30T00:00:02.000Z',
137
+ type: 'event_msg',
138
+ payload: { type: 'user_message', message: 'Legacy prompt' },
139
+ },
140
+ {
141
+ timestamp: '2026-08-30T00:00:03.000Z',
142
+ type: 'event_msg',
143
+ payload: { type: 'agent_message', message: 'Legacy reply' },
144
+ },
145
+ {
146
+ timestamp: '2026-08-30T00:00:04.000Z',
147
+ type: 'event_msg',
148
+ payload: { type: 'task_complete' },
149
+ },
150
+ {
151
+ timestamp: '2026-08-31T00:00:01.000Z',
152
+ type: 'event_msg',
153
+ payload: { type: 'task_started', turn_id: 'modern-turn' },
154
+ },
155
+ {
156
+ timestamp: '2026-08-31T00:00:02.000Z',
157
+ type: 'response_item',
158
+ payload: {
159
+ id: 'modern-user',
160
+ type: 'message',
161
+ role: 'user',
162
+ content: [{ type: 'input_text', text: 'Modern prompt' }],
163
+ },
164
+ },
165
+ {
166
+ timestamp: '2026-08-31T00:00:03.000Z',
167
+ type: 'response_item',
168
+ payload: {
169
+ id: 'modern-agent',
170
+ type: 'message',
171
+ role: 'assistant',
172
+ content: [{ type: 'output_text', text: 'Modern reply' }],
173
+ },
174
+ },
175
+ {
176
+ timestamp: '2026-08-31T00:00:04.000Z',
177
+ type: 'event_msg',
178
+ payload: { type: 'task_complete' },
179
+ },
180
+ ]
181
+ .map((entry) => JSON.stringify(entry))
182
+ .join('\n'),
183
+ );
184
+
185
+ const session = await new LocalCodexSessionStore(codexHome).findSession(
186
+ sessionId,
187
+ );
188
+
189
+ expect(session?.turns.map((turn) => turn.items.map((item) => item.text))).toEqual([
190
+ ['Legacy prompt', 'Legacy reply'],
191
+ ['Modern prompt', 'Modern reply'],
192
+ ]);
193
+ });
194
+
195
+ it('merges complete paginated tool history with newer rollout-only turns', async () => {
196
+ const codexHome = await fs.mkdtemp(
197
+ path.join(os.tmpdir(), 'remote-codex-paginated-session-'),
198
+ );
199
+ tempDirectories.push(codexHome);
200
+ const sessionId = '01a059bf-c303-79b1-8802-f922d3a81970';
201
+ const workspacePath = path.join(codexHome, 'workspace');
202
+ const sessionsDirectory = path.join(codexHome, 'sessions');
203
+ await fs.mkdir(sessionsDirectory, { recursive: true });
204
+ await fs.writeFile(
205
+ path.join(sessionsDirectory, `rollout-${sessionId}.jsonl`),
206
+ [
207
+ {
208
+ timestamp: '2026-08-31T00:00:00.000Z',
209
+ type: 'session_meta',
210
+ payload: { id: sessionId, cwd: workspacePath },
211
+ },
212
+ {
213
+ timestamp: '2026-08-31T00:00:01.000Z',
214
+ type: 'event_msg',
215
+ payload: { type: 'task_started', turn_id: 'turn-rich' },
216
+ },
217
+ {
218
+ timestamp: '2026-08-31T00:00:02.000Z',
219
+ type: 'response_item',
220
+ payload: {
221
+ id: 'user-rich',
222
+ type: 'message',
223
+ role: 'user',
224
+ content: [{ type: 'input_text', text: 'Inspect.' }],
225
+ },
226
+ },
227
+ {
228
+ timestamp: '2026-08-31T00:00:03.500Z',
229
+ type: 'response_item',
230
+ payload: {
231
+ id: 'agent-commentary',
232
+ type: 'message',
233
+ role: 'assistant',
234
+ phase: 'commentary',
235
+ content: [{ type: 'output_text', text: 'Running checks.' }],
236
+ },
237
+ },
238
+ {
239
+ timestamp: '2026-08-31T00:00:05.000Z',
240
+ type: 'response_item',
241
+ payload: {
242
+ id: 'agent-rich',
243
+ type: 'message',
244
+ role: 'assistant',
245
+ content: [{ type: 'output_text', text: 'Done.' }],
246
+ },
247
+ },
248
+ {
249
+ timestamp: '2026-08-31T00:00:06.000Z',
250
+ type: 'event_msg',
251
+ payload: { type: 'task_complete' },
252
+ },
253
+ {
254
+ timestamp: '2026-08-31T00:01:00.000Z',
255
+ type: 'event_msg',
256
+ payload: { type: 'task_started', turn_id: 'turn-new' },
257
+ },
258
+ {
259
+ timestamp: '2026-08-31T00:01:01.000Z',
260
+ type: 'response_item',
261
+ payload: {
262
+ id: 'user-new',
263
+ type: 'message',
264
+ role: 'user',
265
+ content: [{ type: 'input_text', text: 'Continue.' }],
266
+ },
267
+ },
268
+ {
269
+ timestamp: '2026-08-31T00:01:02.000Z',
270
+ type: 'event_msg',
271
+ payload: {
272
+ type: 'item_completed',
273
+ turn_id: 'turn-new',
274
+ started_at_ms: 1_788_134_462_000,
275
+ item: {
276
+ id: 'command-new',
277
+ type: 'CommandExecution',
278
+ command: 'git status --short',
279
+ aggregated_output: '',
280
+ status: 'completed',
281
+ exit_code: 0,
282
+ },
283
+ },
284
+ },
285
+ ].map((entry) => JSON.stringify(entry)).join('\n'),
286
+ );
287
+
288
+ const sqlite = new Database(path.join(codexHome, 'thread_history_1.sqlite'));
289
+ sqlite.exec(`
290
+ CREATE TABLE thread_turns (
291
+ thread_id TEXT NOT NULL,
292
+ turn_id TEXT NOT NULL,
293
+ rollout_ordinal INTEGER NOT NULL,
294
+ status TEXT NOT NULL,
295
+ error_json TEXT,
296
+ started_at INTEGER,
297
+ PRIMARY KEY (thread_id, turn_id)
298
+ );
299
+ CREATE TABLE thread_items (
300
+ thread_id TEXT NOT NULL,
301
+ turn_id TEXT NOT NULL,
302
+ item_id TEXT NOT NULL,
303
+ rollout_ordinal INTEGER NOT NULL,
304
+ created_at_ms INTEGER NOT NULL,
305
+ item_type TEXT NOT NULL,
306
+ item_json TEXT NOT NULL,
307
+ PRIMARY KEY (thread_id, turn_id, item_id)
308
+ );
309
+ `);
310
+ sqlite.prepare(
311
+ 'INSERT INTO thread_turns VALUES (?, ?, ?, ?, ?, ?)',
312
+ ).run(sessionId, 'turn-rich', 1, 'completed', null, 1_788_134_401);
313
+ const insertItem = sqlite.prepare(
314
+ 'INSERT INTO thread_items VALUES (?, ?, ?, ?, ?, ?, ?)',
315
+ );
316
+ const rawItems = [
317
+ {
318
+ ordinal: 2,
319
+ createdAtMs: 1_788_134_402_000,
320
+ type: 'userMessage',
321
+ item: {
322
+ id: 'user-rich',
323
+ type: 'userMessage',
324
+ content: [{ type: 'text', text: 'Inspect.' }],
325
+ },
326
+ },
327
+ {
328
+ ordinal: 3,
329
+ createdAtMs: 1_788_134_403_000,
330
+ type: 'reasoning',
331
+ item: {
332
+ id: 'reason-rich',
333
+ type: 'reasoning',
334
+ summary: ['Checking the workspace.'],
335
+ },
336
+ },
337
+ {
338
+ ordinal: 4,
339
+ createdAtMs: 1_788_134_404_000,
340
+ type: 'commandExecution',
341
+ item: {
342
+ id: 'command-rich',
343
+ type: 'commandExecution',
344
+ command: 'pwd',
345
+ aggregatedOutput: workspacePath,
346
+ status: 'completed',
347
+ exitCode: 0,
348
+ },
349
+ },
350
+ {
351
+ ordinal: 5,
352
+ createdAtMs: 1_788_134_405_000,
353
+ type: 'agentMessage',
354
+ item: {
355
+ id: 'agent-rich',
356
+ type: 'agentMessage',
357
+ text: 'Done.',
358
+ },
359
+ },
360
+ ];
361
+ for (const raw of rawItems) {
362
+ insertItem.run(
363
+ sessionId,
364
+ 'turn-rich',
365
+ raw.item.id,
366
+ raw.ordinal,
367
+ raw.createdAtMs,
368
+ raw.type,
369
+ JSON.stringify(raw.item),
370
+ );
371
+ }
372
+ sqlite.close();
373
+
374
+ const session = await new LocalCodexSessionStore(codexHome).findSession(
375
+ sessionId,
376
+ );
377
+
378
+ expect(session?.turns).toHaveLength(2);
379
+ expect(session?.turns[0]).toMatchObject({
380
+ id: 'turn-rich',
381
+ status: 'completed',
382
+ items: [
383
+ { id: 'user-rich', kind: 'userMessage' },
384
+ { id: 'reason-rich', kind: 'reasoning' },
385
+ { id: 'agent-commentary', kind: 'agentMessage' },
386
+ { id: 'command-rich', kind: 'commandExecution' },
387
+ { id: 'agent-rich', kind: 'agentMessage' },
388
+ ],
389
+ });
390
+ expect(session?.turns[1]).toMatchObject({
391
+ id: 'turn-new',
392
+ status: 'inProgress',
393
+ items: [
394
+ { id: 'user-new', kind: 'userMessage' },
395
+ { id: 'command-new', kind: 'commandExecution' },
396
+ ],
397
+ });
398
+ });
399
+
400
+ it('watches rollout changes for externally running imported sessions', async () => {
401
+ const codexHome = await fs.mkdtemp(
402
+ path.join(os.tmpdir(), 'remote-codex-watched-session-'),
403
+ );
404
+ tempDirectories.push(codexHome);
405
+ const sessionId = '01a059bf-c303-79b1-8802-f922d3a81971';
406
+ const sessionsDirectory = path.join(codexHome, 'sessions');
407
+ const transcriptPath = path.join(
408
+ sessionsDirectory,
409
+ `rollout-${sessionId}.jsonl`,
410
+ );
411
+ await fs.mkdir(sessionsDirectory, { recursive: true });
412
+ await fs.writeFile(
413
+ transcriptPath,
414
+ `${JSON.stringify({
415
+ timestamp: '2026-08-31T00:00:00.000Z',
416
+ type: 'session_meta',
417
+ payload: { id: sessionId, cwd: codexHome },
418
+ })}\n`,
419
+ );
420
+ const store = new LocalCodexSessionStore(codexHome, {
421
+ watchIntervalMs: 20,
422
+ watchThrottleMs: 25,
423
+ });
424
+ let changes = 0;
425
+ let resolveChange: (() => void) | null = null;
426
+ const changed = new Promise<void>((resolve) => {
427
+ resolveChange = resolve;
428
+ });
429
+ const stop = await store.watchSession(sessionId, () => {
430
+ changes += 1;
431
+ resolveChange?.();
432
+ });
433
+
434
+ await new Promise((resolve) => setTimeout(resolve, 40));
435
+ await fs.appendFile(
436
+ transcriptPath,
437
+ `${JSON.stringify({
438
+ timestamp: '2026-08-31T00:00:01.000Z',
439
+ type: 'event_msg',
440
+ payload: { type: 'task_started', turn_id: 'turn-1' },
441
+ })}\n`,
442
+ );
443
+ await Promise.race([
444
+ changed,
445
+ new Promise((_, reject) =>
446
+ setTimeout(() => reject(new Error('watcher timed out')), 2_000),
447
+ ),
448
+ ]);
449
+
450
+ stop();
451
+ await fs.appendFile(transcriptPath, '{}\n');
452
+ await new Promise((resolve) => setTimeout(resolve, 100));
453
+ expect(changes).toBe(1);
454
+ });
455
+ });