@codedeck/codedeck 2026.3.35 → 2026.3.39

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.
@@ -1,21 +1,6 @@
1
1
  "use strict";
2
2
  /**
3
3
  * Watches Codex JSONL rollout files for structured events.
4
- *
5
- * Codex writes per-session rollout files to:
6
- * ~/.codex/sessions/YYYY/MM/DD/rollout-YYYY-MM-DDThh-mm-ss-<uuid>.jsonl
7
- *
8
- * The first line of each file is a "session_meta" record whose payload.cwd
9
- * identifies the project directory. We match files to codedeck sessions by
10
- * comparing payload.cwd to the session's workDir.
11
- *
12
- * Events emitted:
13
- * - user.message ← event_msg { type: "user_message", message: "..." }
14
- * - assistant.text ← event_msg { type: "agent_message", phase: "final_answer", message: "..." }
15
- *
16
- * Integration:
17
- * - startWatching(sessionName, workDir) when a codex session starts
18
- * - stopWatching(sessionName) when it stops
19
4
  */
20
5
  var __importDefault = (this && this.__importDefault) || function (mod) {
21
6
  return (mod && mod.__esModule) ? mod : { "default": mod };
@@ -23,26 +8,28 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
23
8
  Object.defineProperty(exports, "__esModule", { value: true });
24
9
  exports.readCwd = readCwd;
25
10
  exports.parseLine = parseLine;
11
+ exports.preClaimFile = preClaimFile;
12
+ exports.isFileClaimedByOther = isFileClaimedByOther;
13
+ exports.extractUuidFromPath = extractUuidFromPath;
26
14
  exports.extractNewRolloutUuid = extractNewRolloutUuid;
27
15
  exports.findRolloutPathByUuid = findRolloutPathByUuid;
28
16
  exports.startWatching = startWatching;
29
- exports.isWatching = isWatching;
30
17
  exports.startWatchingSpecificFile = startWatchingSpecificFile;
18
+ exports.startWatchingById = startWatchingById;
31
19
  exports.stopWatching = stopWatching;
20
+ exports.isWatching = isWatching;
32
21
  const promises_1 = require("fs/promises");
33
22
  const path_1 = require("path");
34
23
  const os_1 = require("os");
35
24
  const timeline_emitter_js_1 = require("./timeline-emitter.js");
36
25
  const logger_js_1 = __importDefault(require("../util/logger.js"));
37
26
  // ── Path helpers ───────────────────────────────────────────────────────────────
38
- /** Return ~/.codex/sessions/YYYY/MM/DD for a given Date. */
39
27
  function codexSessionDir(d) {
40
28
  const yyyy = d.getUTCFullYear();
41
29
  const mm = String(d.getUTCMonth() + 1).padStart(2, '0');
42
30
  const dd = String(d.getUTCDate()).padStart(2, '0');
43
31
  return (0, path_1.join)((0, os_1.homedir)(), '.codex', 'sessions', String(yyyy), mm, dd);
44
32
  }
45
- /** Return the last 30 days of session dirs (newest first). */
46
33
  function recentSessionDirs() {
47
34
  const dirs = [];
48
35
  for (let i = 0; i < 30; i++) {
@@ -52,27 +39,17 @@ function recentSessionDirs() {
52
39
  return dirs;
53
40
  }
54
41
  // ── JSONL matching ─────────────────────────────────────────────────────────────
55
- /**
56
- * Read the first line of a rollout file and return payload.cwd if it's a
57
- * session_meta record, otherwise null.
58
- * Exported for testing.
59
- */
60
42
  async function readCwd(filePath) {
61
43
  let fh = null;
62
44
  try {
63
45
  fh = await (0, promises_1.open)(filePath, 'r');
64
- // The session_meta first line can be very large (includes full conversation context).
65
- // Read only the first 4KB — enough to find the "cwd" field which appears early.
66
- // We extract cwd via regex instead of full JSON.parse to avoid truncation issues.
67
46
  const buf = Buffer.allocUnsafe(4096);
68
47
  const { bytesRead } = await fh.read(buf, 0, 4096, 0);
69
48
  if (bytesRead === 0)
70
49
  return null;
71
50
  const snippet = buf.subarray(0, bytesRead).toString('utf8');
72
- // Verify this is a session_meta line
73
51
  if (!snippet.includes('"session_meta"'))
74
52
  return null;
75
- // Extract "cwd":"..." value — cwd paths don't contain quotes or backslashes
76
53
  const m = /"cwd"\s*:\s*"([^"]+)"/.exec(snippet);
77
54
  return m ? m[1] : null;
78
55
  }
@@ -84,11 +61,7 @@ async function readCwd(filePath) {
84
61
  await fh.close().catch(() => { });
85
62
  }
86
63
  }
87
- /**
88
- * Find the most recent rollout-*.jsonl in dir whose session_meta.cwd matches workDir.
89
- * Returns the file path, or null if none found.
90
- */
91
- async function findLatestRollout(dir, workDir) {
64
+ async function findLatestRollout(dir, workDir, excludeClaimed = true) {
92
65
  let entries;
93
66
  try {
94
67
  entries = await (0, promises_1.readdir)(dir);
@@ -96,26 +69,22 @@ async function findLatestRollout(dir, workDir) {
96
69
  catch {
97
70
  return null;
98
71
  }
99
- const rollouts = entries.filter((e) => e.startsWith('rollout-') && e.endsWith('.jsonl'));
100
- if (rollouts.length === 0)
101
- return null;
102
- // Sort newest first by filename (timestamps embedded in name)
103
- rollouts.sort((a, b) => b.localeCompare(a));
72
+ const rollouts = entries.filter((e) => e.startsWith('rollout-') && e.endsWith('.jsonl')).sort().reverse();
104
73
  for (const name of rollouts) {
105
74
  const fpath = (0, path_1.join)(dir, name);
75
+ if (excludeClaimed) {
76
+ const owner = claimedFiles.get(fpath);
77
+ if (owner && owner !== 'UNKNOWN')
78
+ continue;
79
+ }
106
80
  const cwd = await readCwd(fpath);
107
- if (cwd && normalizePath(cwd) === normalizePath(workDir)) {
81
+ if (cwd && normalizePath(cwd) === normalizePath(workDir))
108
82
  return fpath;
109
- }
110
83
  }
111
84
  return null;
112
85
  }
113
- function normalizePath(p) {
114
- return p.replace(/\/+$/, '');
115
- }
86
+ function normalizePath(p) { return p.replace(/\/+$/, ''); }
116
87
  // ── JSONL parsing ──────────────────────────────────────────────────────────────
117
- // Debounce buffers for streaming final_answer events.
118
- // Codex emits a new final_answer snapshot on every token; we only want the last one.
119
88
  const finalAnswerBuffers = new Map();
120
89
  const FINAL_ANSWER_DEBOUNCE_MS = 600;
121
90
  function flushFinalAnswer(sessionName) {
@@ -125,7 +94,6 @@ function flushFinalAnswer(sessionName) {
125
94
  finalAnswerBuffers.delete(sessionName);
126
95
  timeline_emitter_js_1.timelineEmitter.emit(sessionName, 'assistant.text', { text: buf.text, streaming: false }, { source: 'daemon', confidence: 'high' });
127
96
  }
128
- /** Exported for testing. */
129
97
  function parseLine(sessionName, line, model) {
130
98
  if (!line.trim())
131
99
  return;
@@ -136,99 +104,67 @@ function parseLine(sessionName, line, model) {
136
104
  catch {
137
105
  return;
138
106
  }
139
- // Handle Codex function/tool calls: type=response_item, payload.type=function_call|function_call_output
140
- if (raw['type'] === 'response_item') {
141
- const pl = raw['payload'];
107
+ if (raw.type === 'response_item') {
108
+ const pl = raw.payload;
142
109
  if (!pl)
143
110
  return;
144
- if (pl['type'] === 'function_call') {
145
- const name = String(pl['name'] ?? 'tool');
146
- const argsStr = pl['arguments'];
147
- let input = argsStr ?? '';
111
+ if (pl.type === 'function_call') {
112
+ const name = String(pl.name ?? 'tool');
113
+ let input = pl.arguments ?? '';
148
114
  try {
149
- const args = JSON.parse(argsStr ?? '{}');
150
- // Surface the most meaningful field as the summary
151
- const summary = args['cmd'] ?? args['command'] ?? args['path'] ?? args['query'] ?? args['input'];
115
+ const args = JSON.parse(pl.arguments ?? '{}');
116
+ const summary = args.cmd ?? args.command ?? args.path ?? args.query ?? args.input;
152
117
  if (summary !== undefined)
153
118
  input = String(summary);
154
119
  }
155
- catch { /* keep raw args */ }
156
- timeline_emitter_js_1.timelineEmitter.emit(sessionName, 'tool.call', {
157
- tool: name,
158
- input,
159
- callId: pl['call_id'],
160
- }, { source: 'daemon', confidence: 'high' });
120
+ catch { }
121
+ timeline_emitter_js_1.timelineEmitter.emit(sessionName, 'tool.call', { tool: name, ...(input ? { input } : {}) }, { source: 'daemon', confidence: 'high' });
161
122
  }
162
- else if (pl['type'] === 'function_call_output') {
163
- const output = String(pl['output'] ?? '');
164
- timeline_emitter_js_1.timelineEmitter.emit(sessionName, 'tool.result', {
165
- output: output.length > 400 ? output.slice(0, 400) + '…' : output,
166
- callId: pl['call_id'],
167
- }, { source: 'daemon', confidence: 'high' });
123
+ else if (pl.type === 'function_call_output') {
124
+ const errMsg = pl.error;
125
+ timeline_emitter_js_1.timelineEmitter.emit(sessionName, 'tool.result', { ...(errMsg ? { error: errMsg } : {}) }, { source: 'daemon', confidence: 'high' });
168
126
  }
169
127
  return;
170
128
  }
171
- if (raw['type'] !== 'event_msg')
129
+ if (raw.type !== 'event_msg')
172
130
  return;
173
- const payload = raw['payload'];
174
- if (!payload)
131
+ const pl = raw.payload;
132
+ if (!pl)
175
133
  return;
176
- const evtType = payload['type'];
177
- if (evtType === 'token_count') {
178
- const info = payload['info'];
179
- const last = info?.['last_token_usage'];
180
- const ctxWin = info?.['model_context_window'] ?? 1_000_000;
181
- if (last && typeof last['input_tokens'] === 'number') {
134
+ if (pl.type === 'token_count') {
135
+ const last = pl.info?.last_token_usage;
136
+ if (last && typeof last.input_tokens === 'number') {
182
137
  timeline_emitter_js_1.timelineEmitter.emit(sessionName, 'usage.update', {
183
- inputTokens: last['input_tokens'],
184
- cacheTokens: last['cached_input_tokens'] ?? 0,
185
- contextWindow: ctxWin,
138
+ inputTokens: last.input_tokens,
139
+ cacheTokens: last.cached_input_tokens ?? 0,
140
+ contextWindow: pl.info.model_context_window ?? 1000000,
186
141
  ...(model ? { model } : {}),
187
142
  }, { source: 'daemon', confidence: 'high' });
188
143
  }
189
- return;
190
144
  }
191
- if (evtType === 'user_message') {
192
- // Flush any pending assistant text before a new user message
145
+ else if (pl.type === 'user_message') {
193
146
  flushFinalAnswer(sessionName);
194
- const text = payload['message'];
195
- if (text?.trim()) {
196
- timeline_emitter_js_1.timelineEmitter.emit(sessionName, 'user.message', { text }, { source: 'daemon', confidence: 'high' });
197
- }
198
- return;
147
+ if (pl.message?.trim())
148
+ timeline_emitter_js_1.timelineEmitter.emit(sessionName, 'user.message', { text: pl.message }, { source: 'daemon', confidence: 'high' });
199
149
  }
200
- if (evtType === 'agent_message' && payload['phase'] === 'commentary') {
201
- // Tool-call interstitial commentary — emit immediately as streaming indicator
202
- const text = payload['message'];
150
+ else if (pl.type === 'agent_message') {
151
+ const text = pl.message;
203
152
  if (!text?.trim())
204
153
  return;
205
- timeline_emitter_js_1.timelineEmitter.emit(sessionName, 'assistant.text', { text: `_${text}_`, streaming: true }, { source: 'daemon', confidence: 'high' });
206
- return;
207
- }
208
- if (evtType === 'agent_reasoning') {
209
- // Model reasoning/thinking — emit as streaming indicator
210
- const text = payload['message'];
211
- if (!text?.trim())
212
- return;
213
- timeline_emitter_js_1.timelineEmitter.emit(sessionName, 'assistant.text', { text: `_${text}_`, streaming: true }, { source: 'daemon', confidence: 'high' });
214
- return;
215
- }
216
- if (evtType === 'agent_message' && payload['phase'] === 'final_answer') {
217
- const text = payload['message'];
218
- if (!text?.trim())
219
- return;
220
- // Emit immediately as streaming update, debounce the final non-streaming emit
221
- timeline_emitter_js_1.timelineEmitter.emit(sessionName, 'assistant.text', { text, streaming: true }, { source: 'daemon', confidence: 'high' });
222
- // Debounce: buffer the latest snapshot and reset the timer
223
- const existing = finalAnswerBuffers.get(sessionName);
224
- if (existing)
225
- clearTimeout(existing.timer);
226
- const timer = setTimeout(() => flushFinalAnswer(sessionName), FINAL_ANSWER_DEBOUNCE_MS);
227
- finalAnswerBuffers.set(sessionName, { text, timer });
154
+ if (pl.phase === 'final_answer') {
155
+ timeline_emitter_js_1.timelineEmitter.emit(sessionName, 'assistant.text', { text, streaming: true }, { source: 'daemon', confidence: 'high' });
156
+ const existing = finalAnswerBuffers.get(sessionName);
157
+ if (existing)
158
+ clearTimeout(existing.timer);
159
+ const timer = setTimeout(() => flushFinalAnswer(sessionName), FINAL_ANSWER_DEBOUNCE_MS);
160
+ finalAnswerBuffers.set(sessionName, { text, timer });
161
+ }
162
+ else if (pl.phase === 'commentary') {
163
+ timeline_emitter_js_1.timelineEmitter.emit(sessionName, 'assistant.text', { text: `_${text}_`, streaming: true }, { source: 'daemon', confidence: 'high' });
164
+ }
228
165
  }
229
166
  }
230
167
  // ── History replay ─────────────────────────────────────────────────────────────
231
- const HISTORY_LINES = 200;
232
168
  async function emitRecentHistory(sessionName, filePath, model) {
233
169
  let fh = null;
234
170
  try {
@@ -239,123 +175,53 @@ async function emitRecentHistory(sessionName, filePath, model) {
239
175
  const readSize = Math.min(size, 256 * 1024);
240
176
  const buf = Buffer.allocUnsafe(readSize);
241
177
  const { bytesRead } = await fh.read(buf, 0, readSize, size - readSize);
242
- if (bytesRead === 0)
243
- return;
244
178
  const chunk = buf.subarray(0, bytesRead).toString('utf8');
245
179
  const lines = chunk.split('\n');
246
- const startIdx = size > readSize ? 1 : 0; // skip possible partial first line
247
- const historyEvents = [];
248
- let lastTokenPayload = null;
180
+ const startIdx = size > readSize ? 1 : 0;
181
+ let bytePos = size - readSize;
182
+ for (let i = 0; i < startIdx; i++)
183
+ bytePos += Buffer.byteLength(lines[i], 'utf8') + 1;
249
184
  for (let i = startIdx; i < lines.length; i++) {
185
+ const lineBytePos = bytePos;
186
+ bytePos += Buffer.byteLength(lines[i], 'utf8') + 1;
250
187
  const line = lines[i];
251
188
  if (!line.trim())
252
189
  continue;
253
- let raw;
254
- try {
255
- raw = JSON.parse(line);
256
- }
257
- catch {
258
- continue;
259
- }
260
- // Tool calls (response_item)
261
- if (raw['type'] === 'response_item') {
262
- const pl = raw['payload'];
263
- if (!pl)
264
- continue;
265
- if (pl['type'] === 'function_call') {
266
- const name = String(pl['name'] ?? 'tool');
267
- const argsStr = pl['arguments'];
268
- let input = argsStr ?? '';
269
- try {
270
- const args = JSON.parse(argsStr ?? '{}');
271
- const summary = args['cmd'] ?? args['command'] ?? args['path'] ?? args['query'] ?? args['input'];
272
- if (summary !== undefined)
273
- input = String(summary);
274
- }
275
- catch { /* keep raw */ }
276
- historyEvents.push({ type: 'tool_call', name, input, callId: String(pl['call_id'] ?? '') });
277
- }
278
- else if (pl['type'] === 'function_call_output') {
279
- const output = String(pl['output'] ?? '');
280
- historyEvents.push({ type: 'tool_result', output: output.length > 400 ? output.slice(0, 400) + '…' : output, callId: String(pl['call_id'] ?? '') });
281
- }
282
- continue;
283
- }
284
- if (raw['type'] !== 'event_msg')
285
- continue;
286
- const payload = raw['payload'];
287
- if (!payload)
288
- continue;
289
- const evtType = payload['type'];
290
- if (evtType === 'user_message') {
291
- const text = payload['message'];
292
- if (text?.trim())
293
- historyEvents.push({ type: 'user', text });
294
- }
295
- else if (evtType === 'agent_message' && payload['phase'] === 'final_answer') {
296
- const text = payload['message'];
297
- if (!text?.trim())
298
- continue;
299
- const last = historyEvents[historyEvents.length - 1];
300
- if (last?.type === 'assistant') {
301
- last.text = text;
302
- }
303
- else {
304
- historyEvents.push({ type: 'assistant', text });
305
- }
306
- }
307
- else if (evtType === 'token_count') {
308
- const info = payload['info'];
309
- const last = info?.['last_token_usage'];
310
- const ctxWin = info?.['model_context_window'] ?? 1_000_000;
311
- if (last && typeof last['input_tokens'] === 'number') {
312
- lastTokenPayload = {
313
- inputTokens: last['input_tokens'],
314
- cacheTokens: last['cached_input_tokens'] ?? 0,
315
- contextWindow: ctxWin,
316
- };
317
- }
318
- }
190
+ parseLine(sessionName, line, model); // Simplified for this restoration fix
319
191
  }
320
- // Emit deduplicated history (most recent HISTORY_LINES events)
321
- const slice = historyEvents.slice(-HISTORY_LINES);
322
- for (const ev of slice) {
323
- if (ev.type === 'user') {
324
- timeline_emitter_js_1.timelineEmitter.emit(sessionName, 'user.message', { text: ev.text }, { source: 'daemon', confidence: 'high' });
325
- }
326
- else if (ev.type === 'assistant') {
327
- timeline_emitter_js_1.timelineEmitter.emit(sessionName, 'assistant.text', { text: ev.text, streaming: false }, { source: 'daemon', confidence: 'high' });
328
- }
329
- else if (ev.type === 'tool_call') {
330
- timeline_emitter_js_1.timelineEmitter.emit(sessionName, 'tool.call', { tool: ev.name, input: ev.input, callId: ev.callId }, { source: 'daemon', confidence: 'high' });
331
- }
332
- else if (ev.type === 'tool_result') {
333
- timeline_emitter_js_1.timelineEmitter.emit(sessionName, 'tool.result', { output: ev.output, callId: ev.callId }, { source: 'daemon', confidence: 'high' });
334
- }
335
- }
336
- // Emit last usage snapshot so the context bar populates on load
337
- if (lastTokenPayload) {
338
- timeline_emitter_js_1.timelineEmitter.emit(sessionName, 'usage.update', { ...lastTokenPayload, ...(model ? { model } : {}) }, { source: 'daemon', confidence: 'high' });
339
- }
340
- }
341
- catch {
342
- // best-effort
343
192
  }
193
+ catch { }
344
194
  finally {
345
195
  if (fh)
346
196
  await fh.close().catch(() => { });
347
197
  }
348
198
  }
349
199
  const watchers = new Map();
350
- // ── UUID extraction helpers ────────────────────────────────────────────────────
351
- const UUID_RE = /([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/;
200
+ const claimedFiles = new Map(); // filePath → sessionName
201
+ function preClaimFile(sessionName, filePath) {
202
+ for (const [fp, sn] of claimedFiles) {
203
+ if (sn === sessionName) {
204
+ claimedFiles.delete(fp);
205
+ break;
206
+ }
207
+ }
208
+ claimedFiles.set(filePath, sessionName);
209
+ }
210
+ function isFileClaimedByOther(sessionName, filePath) {
211
+ const owner = claimedFiles.get(filePath);
212
+ return !!(owner && owner !== sessionName && owner !== 'UNKNOWN');
213
+ }
214
+ function extractUuidFromPath(p) {
215
+ const m = /rollout-.*-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/.exec(p);
216
+ return m ? m[1] : null;
217
+ }
352
218
  /**
353
- * Scan the last 30 days of session dirs for a rollout file whose session_meta.cwd matches
354
- * workDir and whose mtime is > since. Returns the UUID from the filename, or null if not found.
355
- * Polls every 1s for up to 60s.
219
+ * Wait for a new rollout file to appear for the given workDir after launchTime.
220
+ * Returns the UUID extracted from the filename, or null if not found within timeout.
356
221
  */
357
- async function extractNewRolloutUuid(workDir, since) {
358
- for (let attempt = 0; attempt < 60; attempt++) {
222
+ async function extractNewRolloutUuid(workDir, launchTime, timeoutMs = 5000) {
223
+ const deadline = Date.now() + timeoutMs;
224
+ while (Date.now() < deadline) {
359
225
  for (const dir of recentSessionDirs()) {
360
226
  let entries;
361
227
  try {
@@ -364,15 +230,13 @@ async function extractNewRolloutUuid(workDir, since) {
364
230
  catch {
365
231
  continue;
366
232
  }
367
- const rollouts = entries.filter((e) => e.startsWith('rollout-') && e.endsWith('.jsonl'));
368
- for (const filename of rollouts) {
369
- const uuidMatch = UUID_RE.exec(filename);
370
- if (!uuidMatch)
233
+ for (const name of entries) {
234
+ if (!name.startsWith('rollout-') || !name.endsWith('.jsonl'))
371
235
  continue;
372
- const fpath = (0, path_1.join)(dir, filename);
236
+ const fpath = (0, path_1.join)(dir, name);
373
237
  try {
374
238
  const s = await (0, promises_1.stat)(fpath);
375
- if (s.mtimeMs <= since)
239
+ if (s.mtimeMs < launchTime)
376
240
  continue;
377
241
  }
378
242
  catch {
@@ -380,18 +244,17 @@ async function extractNewRolloutUuid(workDir, since) {
380
244
  }
381
245
  const cwd = await readCwd(fpath);
382
246
  if (cwd && normalizePath(cwd) === normalizePath(workDir)) {
383
- return uuidMatch[1];
247
+ const uuid = extractUuidFromPath(fpath);
248
+ if (uuid)
249
+ return uuid;
384
250
  }
385
251
  }
386
252
  }
387
- await new Promise((r) => setTimeout(r, 1000));
253
+ await new Promise(r => setTimeout(r, 200));
388
254
  }
389
255
  return null;
390
256
  }
391
- /**
392
- * Find the full path of a rollout file by UUID, scanning the last 30 days.
393
- * Returns null if not found.
394
- */
257
+ /** Search recent session dirs for the rollout file containing the given UUID. */
395
258
  async function findRolloutPathByUuid(uuid) {
396
259
  for (const dir of recentSessionDirs()) {
397
260
  let entries;
@@ -401,105 +264,111 @@ async function findRolloutPathByUuid(uuid) {
401
264
  catch {
402
265
  continue;
403
266
  }
404
- for (const filename of entries) {
405
- if (!filename.startsWith('rollout-') || !filename.endsWith('.jsonl'))
406
- continue;
407
- const uuidMatch = UUID_RE.exec(filename);
408
- if (uuidMatch && uuidMatch[1] === uuid) {
409
- return (0, path_1.join)(dir, filename);
410
- }
411
- }
267
+ const match = entries.find(e => e.includes(uuid) && e.endsWith('.jsonl'));
268
+ if (match)
269
+ return (0, path_1.join)(dir, match);
412
270
  }
413
271
  return null;
414
272
  }
415
273
  // ── Public API ─────────────────────────────────────────────────────────────────
416
274
  async function startWatching(sessionName, workDir, model) {
417
- if (watchers.has(sessionName)) {
275
+ if (watchers.has(sessionName))
418
276
  stopWatching(sessionName);
419
- }
420
- const state = {
421
- workDir,
422
- activeFile: null,
423
- fileOffset: 0,
424
- abort: new AbortController(),
425
- stopped: false,
426
- ...(model ? { model } : {}),
427
- };
277
+ const state = { workDir, activeFile: null, fileOffset: 0, abort: new AbortController(), stopped: false, model };
428
278
  watchers.set(sessionName, state);
429
- // Search recent dirs for existing rollout matching workDir
430
279
  for (const dir of recentSessionDirs()) {
431
280
  const found = await findLatestRollout(dir, workDir);
432
281
  if (found) {
433
- try {
434
- const s = await (0, promises_1.stat)(found);
435
- state.activeFile = found;
436
- state.fileOffset = s.size;
437
- await emitRecentHistory(sessionName, found, state.model);
438
- }
439
- catch {
440
- state.activeFile = found;
441
- state.fileOffset = 0;
442
- }
282
+ const s = await (0, promises_1.stat)(found);
283
+ state.activeFile = found;
284
+ state.fileOffset = s.size;
285
+ claimedFiles.set(found, sessionName);
286
+ await emitRecentHistory(sessionName, found, model);
443
287
  break;
444
288
  }
445
289
  }
446
- // Poll every 2s as fallback (fs.watch on macOS misses file appends)
447
- state.pollTimer = setInterval(() => {
448
- void drainNewLines(sessionName, state);
449
- }, 2000);
450
- // Watch all recent dirs for new/modified rollout files.
451
- // Only start a watcher for dirs that exist (or today's dir which Codex may create soon).
452
- const todayDir = codexSessionDir(new Date());
453
- for (const dir of recentSessionDirs()) {
454
- const isToday = dir === todayDir;
455
- if (!isToday) {
456
- // Skip non-existent historical dirs to avoid WARN spam
457
- try {
458
- await (0, promises_1.stat)(dir);
459
- }
460
- catch {
461
- continue;
462
- }
463
- }
464
- void watchDir(sessionName, state, dir);
465
- }
290
+ startPoll(sessionName, state);
291
+ void watchDir(sessionName, state, state.workDir || codexSessionDir(new Date()));
466
292
  }
467
- function isWatching(sessionName) {
468
- return watchers.has(sessionName);
469
- }
470
- /**
471
- * Watch a specific rollout file directly (used when UUID is already known).
472
- * The file is expected to already exist.
473
- */
474
293
  async function startWatchingSpecificFile(sessionName, filePath, model) {
475
- if (watchers.has(sessionName)) {
294
+ if (watchers.has(sessionName))
476
295
  stopWatching(sessionName);
477
- }
478
- let fileSize = 0;
296
+ let size = 0;
479
297
  try {
480
- const s = await (0, promises_1.stat)(filePath);
481
- fileSize = s.size;
482
- }
483
- catch {
484
- // file may not exist yet — start from 0
298
+ size = (await (0, promises_1.stat)(filePath)).size;
485
299
  }
300
+ catch { }
486
301
  const dir = filePath.substring(0, filePath.lastIndexOf('/'));
487
- const state = {
488
- workDir: dir,
489
- activeFile: filePath,
490
- fileOffset: fileSize,
491
- abort: new AbortController(),
492
- stopped: false,
493
- ...(model ? { model } : {}),
494
- };
302
+ const state = { workDir: dir, activeFile: filePath, fileOffset: size, abort: new AbortController(), stopped: false, model };
495
303
  watchers.set(sessionName, state);
496
- await emitRecentHistory(sessionName, filePath, state.model);
497
- // Poll every 2s as fallback
304
+ claimedFiles.set(filePath, sessionName);
305
+ await emitRecentHistory(sessionName, filePath, model);
306
+ startPoll(sessionName, state);
307
+ void watchDir(sessionName, state, dir);
308
+ }
309
+ async function startWatchingById(sessionName, uuid, model) {
310
+ if (watchers.has(sessionName))
311
+ stopWatching(sessionName);
312
+ const state = { workDir: '', activeFile: null, fileOffset: 0, abort: new AbortController(), stopped: false, model };
313
+ watchers.set(sessionName, state);
314
+ for (let i = 0; i < 60 && !state.stopped; i++) {
315
+ for (const dir of recentSessionDirs()) {
316
+ try {
317
+ const entries = await (0, promises_1.readdir)(dir);
318
+ const match = entries.find(e => e.includes(uuid));
319
+ if (match) {
320
+ const found = (0, path_1.join)(dir, match);
321
+ state.activeFile = found;
322
+ state.workDir = dir;
323
+ claimedFiles.set(found, sessionName);
324
+ startPoll(sessionName, state);
325
+ void watchDir(sessionName, state, dir);
326
+ return;
327
+ }
328
+ }
329
+ catch { }
330
+ }
331
+ await new Promise(r => setTimeout(r, 500));
332
+ }
333
+ }
334
+ function startPoll(sessionName, state) {
498
335
  state.pollTimer = setInterval(() => {
499
- void drainNewLines(sessionName, state);
336
+ void (async () => {
337
+ await drainNewLines(sessionName, state);
338
+ const now = Date.now();
339
+ if (now - (state._lastRotationCheck || 0) > 30000) {
340
+ state._lastRotationCheck = now;
341
+ const uuid = state.activeFile ? extractUuidFromPath(state.activeFile) : null;
342
+ if (uuid) {
343
+ for (const dir of recentSessionDirs()) {
344
+ if (dir === state.workDir)
345
+ continue;
346
+ try {
347
+ const entries = await (0, promises_1.readdir)(dir);
348
+ const match = entries.find(e => e.includes(uuid));
349
+ if (match) {
350
+ const newPath = (0, path_1.join)(dir, match);
351
+ if (await checkNewer(newPath, state.activeFile)) {
352
+ logger_js_1.default.info({ sessionName, new: newPath }, 'codex-watcher: date rotation detected');
353
+ if (state.activeFile)
354
+ claimedFiles.delete(state.activeFile);
355
+ state.activeFile = newPath;
356
+ state.workDir = dir;
357
+ state.fileOffset = 0;
358
+ claimedFiles.set(newPath, sessionName);
359
+ void watchDir(sessionName, state, dir);
360
+ break;
361
+ }
362
+ }
363
+ }
364
+ catch {
365
+ continue;
366
+ }
367
+ }
368
+ }
369
+ }
370
+ })();
500
371
  }, 2000);
501
- // Watch the parent dir for changes to this specific file
502
- void watchDir(sessionName, state, dir);
503
372
  }
504
373
  function stopWatching(sessionName) {
505
374
  const state = watchers.get(sessionName);
@@ -510,70 +379,29 @@ function stopWatching(sessionName) {
510
379
  if (state.pollTimer)
511
380
  clearInterval(state.pollTimer);
512
381
  watchers.delete(sessionName);
513
- // Flush any buffered final_answer on stop
514
- flushFinalAnswer(sessionName);
515
- const buf = finalAnswerBuffers.get(sessionName);
516
- if (buf) {
517
- clearTimeout(buf.timer);
518
- finalAnswerBuffers.delete(sessionName);
382
+ for (const [fp, sn] of claimedFiles) {
383
+ if (sn === sessionName)
384
+ claimedFiles.delete(fp);
519
385
  }
520
386
  }
521
- // ── Internal watcher logic ─────────────────────────────────────────────────────
387
+ function isWatching(sessionName) { return watchers.has(sessionName); }
522
388
  async function watchDir(sessionName, state, dir) {
523
- // Wait for dir to exist (Codex may not have created it yet)
524
- for (let i = 0; i < 60; i++) {
525
- if (state.stopped)
526
- return;
527
- try {
528
- await (0, promises_1.stat)(dir);
529
- break;
530
- }
531
- catch {
532
- await new Promise((r) => setTimeout(r, 1000));
533
- }
534
- }
535
- if (state.stopped)
536
- return;
537
389
  try {
538
390
  const watcher = (0, promises_1.watch)(dir, { persistent: false, signal: state.abort.signal });
539
391
  for await (const event of watcher) {
540
392
  if (state.stopped)
541
393
  break;
542
- if (typeof event.filename !== 'string')
543
- continue;
544
- if (!event.filename.startsWith('rollout-') || !event.filename.endsWith('.jsonl'))
545
- continue;
546
- const changedFile = (0, path_1.join)(dir, event.filename);
547
- if (changedFile !== state.activeFile) {
548
- // New file — check if it matches our workDir and is newer
549
- const cwd = await readCwd(changedFile);
550
- if (!cwd || normalizePath(cwd) !== normalizePath(state.workDir))
551
- continue;
552
- const isNewer = await checkNewer(changedFile, state.activeFile);
553
- if (isNewer || !state.activeFile) {
554
- logger_js_1.default.debug({ sessionName, file: event.filename }, 'codex-watcher: switching to new rollout file');
555
- state.activeFile = changedFile;
556
- state.fileOffset = 0;
557
- }
558
- else {
559
- continue;
560
- }
561
- }
562
- await drainNewLines(sessionName, state);
563
- }
564
- }
565
- catch (err) {
566
- if (!state.stopped) {
567
- logger_js_1.default.warn({ sessionName, dir, err }, 'codex-watcher: dir watch error');
394
+ if (event.filename?.startsWith('rollout-'))
395
+ await drainNewLines(sessionName, state);
568
396
  }
569
397
  }
398
+ catch { }
570
399
  }
571
- async function checkNewer(candidate, current) {
572
- if (!current)
400
+ async function checkNewer(a, b) {
401
+ if (!b)
573
402
  return true;
574
403
  try {
575
- const [cs, curS] = await Promise.all([(0, promises_1.stat)(candidate), (0, promises_1.stat)(current)]);
576
- return cs.mtimeMs > curS.mtimeMs;
404
+ return (await (0, promises_1.stat)(a)).mtimeMs > (await (0, promises_1.stat)(b)).mtimeMs;
577
405
  }
578
406
  catch {
579
407
  return false;
@@ -585,13 +413,11 @@ async function drainNewLines(sessionName, state) {
585
413
  let fh = null;
586
414
  try {
587
415
  fh = await (0, promises_1.open)(state.activeFile, 'r');
588
- const fileStat = await fh.stat();
589
- if (fileStat.size <= state.fileOffset)
416
+ const s = await fh.stat();
417
+ if (s.size <= state.fileOffset)
590
418
  return;
591
- const buf = Buffer.allocUnsafe(fileStat.size - state.fileOffset);
419
+ const buf = Buffer.allocUnsafe(s.size - state.fileOffset);
592
420
  const { bytesRead } = await fh.read(buf, 0, buf.length, state.fileOffset);
593
- if (bytesRead === 0)
594
- return;
595
421
  state.fileOffset += bytesRead;
596
422
  const chunk = buf.subarray(0, bytesRead).toString('utf8');
597
423
  for (const line of chunk.split('\n')) {
@@ -600,11 +426,7 @@ async function drainNewLines(sessionName, state) {
600
426
  parseLine(sessionName, line, state.model);
601
427
  }
602
428
  }
603
- catch (err) {
604
- if (!state.stopped) {
605
- logger_js_1.default.debug({ sessionName, err }, 'codex-watcher: drain error');
606
- }
607
- }
429
+ catch { }
608
430
  finally {
609
431
  if (fh)
610
432
  await fh.close().catch(() => { });