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