@cubicj/codex-watch 0.2.0 → 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
@@ -3,6 +3,7 @@ const fs = require("fs");
3
3
  const path = require("path");
4
4
  const os = require("os");
5
5
  const childProcess = require("child_process");
6
+ const crypto = require("node:crypto");
6
7
  const { StringDecoder } = require("node:string_decoder");
7
8
 
8
9
  process.stdout.on("error", (error) => {
@@ -58,11 +59,11 @@ if (args.includes("--help") || args.includes("-h")) {
58
59
  const once = args.includes("--once");
59
60
  const repoRoot = resolveRepoRoot();
60
61
  const slug = makeSlug(repoRoot);
62
+ const stateDirName = makeStateDirName(repoRoot, slug);
61
63
  const stateRoot = process.env.CLAUDE_PLUGIN_DATA
62
64
  ? path.join(process.env.CLAUDE_PLUGIN_DATA, "state")
63
65
  : path.join(os.homedir(), ".claude/plugins/data/codex-openai-codex/state");
64
66
  const codexHome = process.env.CODEX_HOME || path.join(os.homedir(), ".codex");
65
- const stateDirPattern = new RegExp("^" + escapeRegExp(slug) + "-[0-9a-f]{6,}$");
66
67
 
67
68
  let currentJob = null;
68
69
  let logOffset = 0;
@@ -74,8 +75,11 @@ let scanTimer = null;
74
75
  let waitingPrinted = false;
75
76
  let completionBannerPrinted = false;
76
77
  let sessionInfoPrinted = false;
78
+ let requestedSessionInfoPrinted = false;
79
+ let thinkingIndicatorPrinted = false;
77
80
  let sessionRolloutPath = "";
78
81
  let lastRolloutSearchAt = null;
82
+ let consumedPatchApplyEvents = new Set();
79
83
 
80
84
  process.on("SIGINT", () => {
81
85
  clearTimers();
@@ -92,6 +96,9 @@ if (once) {
92
96
  printHeader(job);
93
97
  printSessionInfo(job);
94
98
  renderWholeLog(job);
99
+ if (isTerminalStatus(job.status)) {
100
+ printCompletionBanner(job, job.displayedCreatedAt);
101
+ }
95
102
  process.exit(0);
96
103
  }
97
104
 
@@ -115,8 +122,13 @@ function makeSlug(root) {
115
122
  return path.basename(root).replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "") || "workspace";
116
123
  }
117
124
 
118
- function escapeRegExp(value) {
119
- return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
125
+ function makeStateDirName(root, rootSlug) {
126
+ let canonical = root;
127
+ try {
128
+ canonical = fs.realpathSync.native(root);
129
+ } catch (_error) {}
130
+ const hash = crypto.createHash("sha256").update(canonical).digest("hex").slice(0, 16);
131
+ return rootSlug + "-" + hash;
120
132
  }
121
133
 
122
134
  function safeReadDir(dir, options) {
@@ -144,49 +156,44 @@ function readJson(file) {
144
156
  }
145
157
 
146
158
  function findNewestJob() {
147
- const stateEntries = safeReadDir(stateRoot, { withFileTypes: true });
159
+ const jobsDir = path.join(stateRoot, stateDirName, "jobs");
160
+ const jobEntries = safeReadDir(jobsDir, { withFileTypes: true });
148
161
  const candidates = [];
149
162
 
150
- for (const entry of stateEntries) {
151
- if (!entry.isDirectory() || !stateDirPattern.test(entry.name)) {
163
+ for (const jobEntry of jobEntries) {
164
+ if (!jobEntry.isFile() || !jobEntry.name.endsWith(".json")) {
152
165
  continue;
153
166
  }
154
167
 
155
- const jobsDir = path.join(stateRoot, entry.name, "jobs");
156
- const jobEntries = safeReadDir(jobsDir, { withFileTypes: true });
157
-
158
- for (const jobEntry of jobEntries) {
159
- if (!jobEntry.isFile() || !jobEntry.name.endsWith(".json")) {
160
- continue;
161
- }
162
-
163
- const id = path.basename(jobEntry.name, ".json");
164
- const jsonPath = path.join(jobsDir, jobEntry.name);
165
- const logPath = path.join(jobsDir, id + ".log");
166
- const meta = readJson(jsonPath);
167
- const stat = safeStat(jsonPath);
168
- const fallbackCreatedAt = stat ? stat.mtime.toISOString() : "";
169
- const createdAt = typeof meta.createdAt === "string" && meta.createdAt ? meta.createdAt : fallbackCreatedAt;
170
-
171
- candidates.push({
172
- id,
173
- jsonPath,
174
- logPath,
175
- createdAt,
176
- displayedCreatedAt: typeof meta.createdAt === "string" && meta.createdAt ? meta.createdAt : createdAt,
177
- startedAt: typeof meta.startedAt === "string" ? meta.startedAt : "",
178
- status: typeof meta.status === "string" ? meta.status : "unknown",
179
- phase: typeof meta.phase === "string" ? meta.phase : "unknown",
180
- summary: typeof meta.summary === "string" ? meta.summary : "",
181
- threadId: typeof meta.threadId === "string" ? meta.threadId : "",
182
- write: typeof meta.write === "boolean" ? meta.write : false
183
- });
184
- }
168
+ const id = path.basename(jobEntry.name, ".json");
169
+ const jsonPath = path.join(jobsDir, jobEntry.name);
170
+ const logPath = path.join(jobsDir, id + ".log");
171
+ const meta = readJson(jsonPath);
172
+ const stat = safeStat(jsonPath);
173
+ const fallbackCreatedAt = stat ? stat.mtime.toISOString() : "";
174
+ const createdAt = typeof meta.createdAt === "string" && meta.createdAt ? meta.createdAt : fallbackCreatedAt;
175
+
176
+ candidates.push({
177
+ id,
178
+ jsonPath,
179
+ logPath,
180
+ createdAt,
181
+ displayedCreatedAt: typeof meta.createdAt === "string" && meta.createdAt ? meta.createdAt : createdAt,
182
+ startedAt: typeof meta.startedAt === "string" ? meta.startedAt : "",
183
+ updatedAt: typeof meta.updatedAt === "string" ? meta.updatedAt : "",
184
+ completedAt: typeof meta.completedAt === "string" ? meta.completedAt : "",
185
+ status: typeof meta.status === "string" ? meta.status : "unknown",
186
+ phase: typeof meta.phase === "string" ? meta.phase : "unknown",
187
+ summary: typeof meta.summary === "string" ? meta.summary : "",
188
+ threadId: typeof meta.threadId === "string" ? meta.threadId : "",
189
+ write: typeof meta.write === "boolean" ? meta.write : false,
190
+ request: meta.request && typeof meta.request === "object" ? meta.request : null
191
+ });
185
192
  }
186
193
 
187
194
  candidates.sort((a, b) => {
188
195
  if (a.createdAt === b.createdAt) {
189
- return a.id.localeCompare(b.id);
196
+ return b.id.localeCompare(a.id);
190
197
  }
191
198
  return a.createdAt < b.createdAt ? 1 : -1;
192
199
  });
@@ -214,7 +221,10 @@ function scanForNewJob() {
214
221
  return;
215
222
  }
216
223
 
217
- if (newest.createdAt > currentJob.createdAt) {
224
+ if (
225
+ newest.createdAt > currentJob.createdAt ||
226
+ (newest.createdAt === currentJob.createdAt && newest.id.localeCompare(currentJob.id) > 0)
227
+ ) {
218
228
  console.log(color("dim", "──────── switched to " + newest.id + " ────────"));
219
229
  switchToJob(newest);
220
230
  }
@@ -229,8 +239,11 @@ function switchToJob(job) {
229
239
  waitingPrinted = false;
230
240
  completionBannerPrinted = false;
231
241
  sessionInfoPrinted = false;
242
+ requestedSessionInfoPrinted = false;
243
+ thinkingIndicatorPrinted = false;
232
244
  sessionRolloutPath = "";
233
245
  lastRolloutSearchAt = null;
246
+ consumedPatchApplyEvents = new Set();
234
247
  if (tailTimer) {
235
248
  clearInterval(tailTimer);
236
249
  }
@@ -256,20 +269,18 @@ function printHeader(job) {
256
269
  }
257
270
 
258
271
  function printSessionInfo(job) {
259
- if (sessionInfoPrinted || !job.threadId) {
272
+ if (sessionInfoPrinted) {
260
273
  return;
261
274
  }
262
275
 
263
- if (!sessionRolloutPath) {
264
- const now = Date.now();
265
- if (lastRolloutSearchAt !== null && now - lastRolloutSearchAt < 5000) {
266
- return;
267
- }
268
- lastRolloutSearchAt = now;
269
- sessionRolloutPath = findRolloutFile(job.threadId);
270
- if (!sessionRolloutPath) {
271
- return;
272
- }
276
+ if (!job.threadId) {
277
+ printRequestedSessionInfo(job);
278
+ return;
279
+ }
280
+
281
+ if (!resolveSessionRolloutPath(job)) {
282
+ printRequestedSessionInfo(job);
283
+ return;
273
284
  }
274
285
 
275
286
  const text = readFileText(sessionRolloutPath);
@@ -295,6 +306,7 @@ function printSessionInfo(job) {
295
306
 
296
307
  const payload = turnContext && turnContext.payload;
297
308
  if (!payload || typeof payload.model !== "string" || !payload.model) {
309
+ printRequestedSessionInfo(job);
298
310
  return;
299
311
  }
300
312
 
@@ -310,6 +322,42 @@ function printSessionInfo(job) {
310
322
  sessionInfoPrinted = true;
311
323
  }
312
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
+
313
361
  function findRolloutFile(threadId) {
314
362
  const sessionsRoot = path.join(codexHome, "sessions");
315
363
  const years = safeReadDir(sessionsRoot, { withFileTypes: true })
@@ -371,7 +419,7 @@ function formatElapsed(createdAt, updatedAt) {
371
419
 
372
420
  function renderWholeLog(job) {
373
421
  const text = readFileText(job.logPath);
374
- renderText(text);
422
+ renderText(text, false, job);
375
423
  }
376
424
 
377
425
  function readAvailableLog() {
@@ -392,7 +440,7 @@ function readAvailableLog() {
392
440
  const chunk = readFileChunk(currentJob.logPath, logOffset, stat.size - logOffset);
393
441
  if (chunk !== null) {
394
442
  logOffset += chunk.length;
395
- renderText(logDecoder.write(chunk), true);
443
+ renderText(logDecoder.write(chunk), true, currentJob);
396
444
  }
397
445
  }
398
446
  }
@@ -401,38 +449,42 @@ function readAvailableLog() {
401
449
  }
402
450
 
403
451
  function checkJobCompletion() {
404
- if (completionBannerPrinted) {
405
- return true;
406
- }
407
-
408
452
  let meta = null;
409
453
  try {
410
454
  meta = JSON.parse(fs.readFileSync(currentJob.jsonPath, "utf8"));
411
455
  } catch (_error) {
412
- return false;
456
+ return;
413
457
  }
414
458
 
415
459
  const status = typeof meta.status === "string" ? meta.status : "unknown";
416
460
  currentJob.threadId = typeof meta.threadId === "string" ? meta.threadId : "";
417
461
  currentJob.write = typeof meta.write === "boolean" ? meta.write : false;
418
462
  currentJob.startedAt = typeof meta.startedAt === "string" ? meta.startedAt : "";
463
+ currentJob.request = meta.request && typeof meta.request === "object" ? meta.request : null;
419
464
  printSessionInfo(currentJob);
465
+ if (completionBannerPrinted) {
466
+ return;
467
+ }
420
468
  if (!isTerminalStatus(status)) {
421
- return true;
469
+ return;
422
470
  }
423
471
 
424
- const createdAt = typeof meta.createdAt === "string" && meta.createdAt ? meta.createdAt : currentJob.displayedCreatedAt;
425
- const startedAt = typeof meta.startedAt === "string" && meta.startedAt ? meta.startedAt : createdAt;
426
- const updatedAt = typeof meta.updatedAt === "string" && meta.updatedAt ? meta.updatedAt : "";
427
- const completedAt = typeof meta.completedAt === "string" && meta.completedAt ? meta.completedAt : updatedAt;
472
+ printCompletionBanner(meta, currentJob.displayedCreatedAt);
473
+ completionBannerPrinted = true;
474
+ }
475
+
476
+ function printCompletionBanner(job, fallbackCreatedAt) {
477
+ const status = typeof job.status === "string" ? job.status : "unknown";
478
+ const createdAt = typeof job.createdAt === "string" && job.createdAt ? job.createdAt : fallbackCreatedAt;
479
+ const startedAt = typeof job.startedAt === "string" && job.startedAt ? job.startedAt : createdAt;
480
+ const updatedAt = typeof job.updatedAt === "string" && job.updatedAt ? job.updatedAt : "";
481
+ const completedAt = typeof job.completedAt === "string" && job.completedAt ? job.completedAt : updatedAt;
428
482
  const elapsed = formatElapsed(startedAt, completedAt);
429
483
  if (status === "completed") {
430
484
  console.log(color("green", "✓ completed · " + elapsed));
431
485
  } else {
432
486
  console.log(color("red", "✗ " + status + " · " + elapsed));
433
487
  }
434
- completionBannerPrinted = true;
435
- return true;
436
488
  }
437
489
 
438
490
  function isTerminalStatus(status) {
@@ -465,7 +517,7 @@ function readFileChunk(file, start, length) {
465
517
  }
466
518
  }
467
519
 
468
- function renderText(text, keepPartial) {
520
+ function renderText(text, keepPartial, job) {
469
521
  if (!text) {
470
522
  return;
471
523
  }
@@ -482,13 +534,14 @@ function renderText(text, keepPartial) {
482
534
  }
483
535
 
484
536
  for (const line of lines) {
485
- renderLine(line);
537
+ renderLine(line, job);
486
538
  }
487
539
  }
488
540
 
489
- function renderLine(line) {
490
- 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+(.*)$/);
491
543
  if (!match) {
544
+ thinkingIndicatorPrinted = false;
492
545
  if (assistantBlock) {
493
546
  console.log(assistantText(line));
494
547
  } else {
@@ -497,7 +550,8 @@ function renderLine(line) {
497
550
  return;
498
551
  }
499
552
 
500
- const event = match[1];
553
+ const eventTimestamp = match[1];
554
+ const event = match[2];
501
555
 
502
556
  if (event === "Assistant message") {
503
557
  assistantBlock = true;
@@ -510,6 +564,16 @@ function renderLine(line) {
510
564
  return;
511
565
  }
512
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
+
513
577
  if (event.startsWith("Running command: ")) {
514
578
  console.log(color("dim", color("blue", "Running command: " + truncate(event.slice("Running command: ".length)))));
515
579
  return;
@@ -531,7 +595,13 @@ function renderLine(line) {
531
595
  return;
532
596
  }
533
597
 
534
- 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.") {
535
605
  console.log(color("yellow", event));
536
606
  return;
537
607
  }
@@ -544,6 +614,81 @@ function renderLine(line) {
544
614
  console.log(event);
545
615
  }
546
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
+
547
692
  function truncate(value) {
548
693
  const maxLength = 140;
549
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.0",
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": {