@cubicj/codex-watch 0.2.1 → 0.3.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.
package/README.md CHANGED
@@ -4,13 +4,17 @@ Live-tail the newest Codex plugin background job log for the current git repo.
4
4
 
5
5
  `codex-watch` is a zero-dependency terminal companion for the [`openai/codex-plugin-cc`](https://github.com/openai/codex-plugin-cc) Claude Code plugin. When Claude Code delegates work to a background Codex job, run `codex-watch` in that repo's terminal to follow the job's log live — and it keeps following across `--resume`.
6
6
 
7
+ ![codex-watch following a background Codex job: a cancelled job, automatic switch to the new job, session info, colored command results, and the completion banner](https://raw.githubusercontent.com/cubicj/codex-watch/main/assets/screenshot.png)
8
+
7
9
  ## What It Does
8
10
 
9
11
  - Resolves the git repo from the terminal's current working directory.
10
12
  - Follows the newest job log written by the `openai/codex-plugin-cc` Claude Code plugin.
11
13
  - Auto-switches when a `--resume` starts a newer job id, so a resumed run is picked up without restarting.
12
14
  - Shows the job's model, reasoning effort, sandbox mode, and write access under the header (resolved from the Codex CLI session files in `$CODEX_HOME`, default `~/.codex`).
15
+ - Shows a dim `Thinking...` indicator for each reasoning stretch.
13
16
  - Colorizes assistant messages, command status, file changes, and job events.
17
+ - Lists each applied file with its change type and a workspace-relative path when available.
14
18
  - Prints a completion banner such as `✓ completed · <duration>` or `✗ <status> · <duration>`.
15
19
  - Drops duplicate captured lines.
16
20
 
package/bin/codex-watch CHANGED
@@ -75,8 +75,11 @@ let scanTimer = null;
75
75
  let waitingPrinted = false;
76
76
  let completionBannerPrinted = false;
77
77
  let sessionInfoPrinted = false;
78
+ let requestedSessionInfoPrinted = false;
79
+ let thinkingIndicatorPrinted = false;
78
80
  let sessionRolloutPath = "";
79
81
  let lastRolloutSearchAt = null;
82
+ let consumedPatchApplyEvents = new Set();
80
83
 
81
84
  process.on("SIGINT", () => {
82
85
  clearTimers();
@@ -183,7 +186,8 @@ function findNewestJob() {
183
186
  phase: typeof meta.phase === "string" ? meta.phase : "unknown",
184
187
  summary: typeof meta.summary === "string" ? meta.summary : "",
185
188
  threadId: typeof meta.threadId === "string" ? meta.threadId : "",
186
- write: typeof meta.write === "boolean" ? meta.write : false
189
+ write: typeof meta.write === "boolean" ? meta.write : false,
190
+ request: meta.request && typeof meta.request === "object" ? meta.request : null
187
191
  });
188
192
  }
189
193
 
@@ -235,8 +239,11 @@ function switchToJob(job) {
235
239
  waitingPrinted = false;
236
240
  completionBannerPrinted = false;
237
241
  sessionInfoPrinted = false;
242
+ requestedSessionInfoPrinted = false;
243
+ thinkingIndicatorPrinted = false;
238
244
  sessionRolloutPath = "";
239
245
  lastRolloutSearchAt = null;
246
+ consumedPatchApplyEvents = new Set();
240
247
  if (tailTimer) {
241
248
  clearInterval(tailTimer);
242
249
  }
@@ -262,20 +269,18 @@ function printHeader(job) {
262
269
  }
263
270
 
264
271
  function printSessionInfo(job) {
265
- if (sessionInfoPrinted || !job.threadId) {
272
+ if (sessionInfoPrinted) {
266
273
  return;
267
274
  }
268
275
 
269
- if (!sessionRolloutPath) {
270
- const now = Date.now();
271
- if (lastRolloutSearchAt !== null && now - lastRolloutSearchAt < 5000) {
272
- return;
273
- }
274
- lastRolloutSearchAt = now;
275
- sessionRolloutPath = findRolloutFile(job.threadId);
276
- if (!sessionRolloutPath) {
277
- return;
278
- }
276
+ if (!job.threadId) {
277
+ printRequestedSessionInfo(job);
278
+ return;
279
+ }
280
+
281
+ if (!resolveSessionRolloutPath(job)) {
282
+ printRequestedSessionInfo(job);
283
+ return;
279
284
  }
280
285
 
281
286
  const text = readFileText(sessionRolloutPath);
@@ -301,6 +306,7 @@ function printSessionInfo(job) {
301
306
 
302
307
  const payload = turnContext && turnContext.payload;
303
308
  if (!payload || typeof payload.model !== "string" || !payload.model) {
309
+ printRequestedSessionInfo(job);
304
310
  return;
305
311
  }
306
312
 
@@ -316,6 +322,42 @@ function printSessionInfo(job) {
316
322
  sessionInfoPrinted = true;
317
323
  }
318
324
 
325
+ function printRequestedSessionInfo(job) {
326
+ const request = job.request;
327
+ if (
328
+ requestedSessionInfoPrinted ||
329
+ !request ||
330
+ typeof request.model !== "string" ||
331
+ !request.model
332
+ ) {
333
+ return;
334
+ }
335
+
336
+ const segments = ["model " + request.model + " (requested)"];
337
+ if (typeof request.effort === "string" && request.effort) {
338
+ segments.push("effort " + request.effort + " (requested)");
339
+ }
340
+ console.log(color("dim", segments.join(" · ")));
341
+ requestedSessionInfoPrinted = true;
342
+ }
343
+
344
+ function resolveSessionRolloutPath(job) {
345
+ if (sessionRolloutPath) {
346
+ return sessionRolloutPath;
347
+ }
348
+ if (!job || !job.threadId) {
349
+ return "";
350
+ }
351
+
352
+ const now = Date.now();
353
+ if (lastRolloutSearchAt !== null && now - lastRolloutSearchAt < 5000) {
354
+ return "";
355
+ }
356
+ lastRolloutSearchAt = now;
357
+ sessionRolloutPath = findRolloutFile(job.threadId);
358
+ return sessionRolloutPath;
359
+ }
360
+
319
361
  function findRolloutFile(threadId) {
320
362
  const sessionsRoot = path.join(codexHome, "sessions");
321
363
  const years = safeReadDir(sessionsRoot, { withFileTypes: true })
@@ -377,7 +419,7 @@ function formatElapsed(createdAt, updatedAt) {
377
419
 
378
420
  function renderWholeLog(job) {
379
421
  const text = readFileText(job.logPath);
380
- renderText(text);
422
+ renderText(text, false, job);
381
423
  }
382
424
 
383
425
  function readAvailableLog() {
@@ -398,7 +440,7 @@ function readAvailableLog() {
398
440
  const chunk = readFileChunk(currentJob.logPath, logOffset, stat.size - logOffset);
399
441
  if (chunk !== null) {
400
442
  logOffset += chunk.length;
401
- renderText(logDecoder.write(chunk), true);
443
+ renderText(logDecoder.write(chunk), true, currentJob);
402
444
  }
403
445
  }
404
446
  }
@@ -407,10 +449,6 @@ function readAvailableLog() {
407
449
  }
408
450
 
409
451
  function checkJobCompletion() {
410
- if (completionBannerPrinted) {
411
- return;
412
- }
413
-
414
452
  let meta = null;
415
453
  try {
416
454
  meta = JSON.parse(fs.readFileSync(currentJob.jsonPath, "utf8"));
@@ -422,7 +460,11 @@ function checkJobCompletion() {
422
460
  currentJob.threadId = typeof meta.threadId === "string" ? meta.threadId : "";
423
461
  currentJob.write = typeof meta.write === "boolean" ? meta.write : false;
424
462
  currentJob.startedAt = typeof meta.startedAt === "string" ? meta.startedAt : "";
463
+ currentJob.request = meta.request && typeof meta.request === "object" ? meta.request : null;
425
464
  printSessionInfo(currentJob);
465
+ if (completionBannerPrinted) {
466
+ return;
467
+ }
426
468
  if (!isTerminalStatus(status)) {
427
469
  return;
428
470
  }
@@ -475,7 +517,7 @@ function readFileChunk(file, start, length) {
475
517
  }
476
518
  }
477
519
 
478
- function renderText(text, keepPartial) {
520
+ function renderText(text, keepPartial, job) {
479
521
  if (!text) {
480
522
  return;
481
523
  }
@@ -492,13 +534,14 @@ function renderText(text, keepPartial) {
492
534
  }
493
535
 
494
536
  for (const line of lines) {
495
- renderLine(line);
537
+ renderLine(line, job);
496
538
  }
497
539
  }
498
540
 
499
- function renderLine(line) {
500
- const match = line.match(/^\[\d{4}-\d{2}-\d{2}T[^\]]*\]\s+(.*)$/);
541
+ function renderLine(line, job) {
542
+ const match = line.match(/^\[(\d{4}-\d{2}-\d{2}T[^\]]*)\]\s+(.*)$/);
501
543
  if (!match) {
544
+ thinkingIndicatorPrinted = false;
502
545
  if (assistantBlock) {
503
546
  console.log(assistantText(line));
504
547
  } else {
@@ -507,7 +550,8 @@ function renderLine(line) {
507
550
  return;
508
551
  }
509
552
 
510
- const event = match[1];
553
+ const eventTimestamp = match[1];
554
+ const event = match[2];
511
555
 
512
556
  if (event === "Assistant message") {
513
557
  assistantBlock = true;
@@ -520,6 +564,16 @@ function renderLine(line) {
520
564
  return;
521
565
  }
522
566
 
567
+ if (event === "Thinking.") {
568
+ if (!thinkingIndicatorPrinted) {
569
+ console.log(color("dim", "Thinking..."));
570
+ thinkingIndicatorPrinted = true;
571
+ }
572
+ return;
573
+ }
574
+
575
+ thinkingIndicatorPrinted = false;
576
+
523
577
  if (event.startsWith("Running command: ")) {
524
578
  console.log(color("dim", color("blue", "Running command: " + truncate(event.slice("Running command: ".length)))));
525
579
  return;
@@ -541,7 +595,13 @@ function renderLine(line) {
541
595
  return;
542
596
  }
543
597
 
544
- if (event.startsWith("File changes") || /^Applying [0-9]+ file change\(s\)\.$/.test(event) || event === "File changes completed.") {
598
+ if (/^Applying [0-9]+ file change\(s\)\.$/.test(event)) {
599
+ console.log(color("yellow", event));
600
+ renderAppliedFiles(eventTimestamp, job);
601
+ return;
602
+ }
603
+
604
+ if (event.startsWith("File changes") || event === "File changes completed.") {
545
605
  console.log(color("yellow", event));
546
606
  return;
547
607
  }
@@ -554,6 +614,81 @@ function renderLine(line) {
554
614
  console.log(event);
555
615
  }
556
616
 
617
+ function renderAppliedFiles(eventTimestamp, job) {
618
+ const rolloutPath = resolveSessionRolloutPath(job);
619
+ if (!rolloutPath) {
620
+ return;
621
+ }
622
+
623
+ const targetTime = Date.parse(eventTimestamp);
624
+ if (!Number.isFinite(targetTime)) {
625
+ return;
626
+ }
627
+
628
+ const text = readFileText(rolloutPath);
629
+ let sessionCwd = process.cwd();
630
+ let nearest = null;
631
+
632
+ for (const [index, line] of text.split(/\r?\n/).entries()) {
633
+ try {
634
+ const entry = JSON.parse(line);
635
+ if (
636
+ entry &&
637
+ entry.type === "session_meta" &&
638
+ entry.payload &&
639
+ typeof entry.payload.cwd === "string" &&
640
+ entry.payload.cwd
641
+ ) {
642
+ sessionCwd = entry.payload.cwd;
643
+ }
644
+ if (
645
+ consumedPatchApplyEvents.has(index) ||
646
+ !entry ||
647
+ entry.type !== "event_msg" ||
648
+ !entry.payload ||
649
+ entry.payload.type !== "patch_apply_end"
650
+ ) {
651
+ continue;
652
+ }
653
+
654
+ const patchTime = Date.parse(entry.timestamp);
655
+ const difference = Math.abs(patchTime - targetTime);
656
+ if (!Number.isFinite(difference) || difference > 2000) {
657
+ continue;
658
+ }
659
+ if (!nearest || difference < nearest.difference) {
660
+ nearest = { index, difference, changes: entry.payload.changes };
661
+ }
662
+ } catch (_error) {}
663
+ }
664
+
665
+ if (!nearest) {
666
+ return;
667
+ }
668
+ consumedPatchApplyEvents.add(nearest.index);
669
+ if (!nearest.changes || typeof nearest.changes !== "object" || Array.isArray(nearest.changes)) {
670
+ return;
671
+ }
672
+
673
+ for (const [file, change] of Object.entries(nearest.changes)) {
674
+ const type = change && change.type;
675
+ const marker = type === "update" ? "M" : type === "add" ? "A" : type === "delete" ? "D" : String(type);
676
+ console.log(color("dim", " " + marker + " " + renderChangedPath(file, sessionCwd)));
677
+ }
678
+ }
679
+
680
+ function renderChangedPath(file, sessionCwd) {
681
+ if (!path.isAbsolute(file)) {
682
+ return file;
683
+ }
684
+
685
+ const relative = path.relative(sessionCwd, file);
686
+ if (relative !== ".." && !relative.startsWith(".." + path.sep) && !path.isAbsolute(relative)) {
687
+ return relative || ".";
688
+ }
689
+ return file;
690
+ }
691
+
557
692
  function truncate(value) {
558
693
  const maxLength = 140;
559
694
  const codePoints = Array.from(value);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cubicj/codex-watch",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "description": "Live-tail the newest Codex plugin background job log for the current git repo.",
5
5
  "license": "MIT",
6
6
  "repository": {