@cubicj/codex-watch 0.1.0 → 0.2.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
@@ -9,6 +9,7 @@ Live-tail the newest Codex plugin background job log for the current git repo.
9
9
  - Resolves the git repo from the terminal's current working directory.
10
10
  - Follows the newest job log written by the `openai/codex-plugin-cc` Claude Code plugin.
11
11
  - Auto-switches when a `--resume` starts a newer job id, so a resumed run is picked up without restarting.
12
+ - 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`).
12
13
  - Colorizes assistant messages, command status, file changes, and job events.
13
14
  - Prints a completion banner such as `✓ completed · <duration>` or `✗ <status> · <duration>`.
14
15
  - Drops duplicate captured lines.
@@ -32,7 +33,7 @@ codex-watch
32
33
  From source:
33
34
 
34
35
  ```sh
35
- git clone <repo-url>
36
+ git clone https://github.com/cubicj/codex-watch.git
36
37
  cd codex-watch
37
38
  npm link
38
39
  codex-watch
package/bin/codex-watch CHANGED
@@ -3,6 +3,14 @@ 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 { StringDecoder } = require("node:string_decoder");
7
+
8
+ process.stdout.on("error", (error) => {
9
+ if (error.code === "EPIPE") {
10
+ process.exit(0);
11
+ }
12
+ throw error;
13
+ });
6
14
 
7
15
  const args = process.argv.slice(2);
8
16
  const useColor = !process.env.NO_COLOR && process.stdout.isTTY;
@@ -53,17 +61,21 @@ const slug = makeSlug(repoRoot);
53
61
  const stateRoot = process.env.CLAUDE_PLUGIN_DATA
54
62
  ? path.join(process.env.CLAUDE_PLUGIN_DATA, "state")
55
63
  : path.join(os.homedir(), ".claude/plugins/data/codex-openai-codex/state");
64
+ const codexHome = process.env.CODEX_HOME || path.join(os.homedir(), ".codex");
56
65
  const stateDirPattern = new RegExp("^" + escapeRegExp(slug) + "-[0-9a-f]{6,}$");
57
66
 
58
67
  let currentJob = null;
59
- let currentKey = "";
60
68
  let logOffset = 0;
69
+ let logDecoder = new StringDecoder("utf8");
61
70
  let pendingText = "";
62
71
  let assistantBlock = false;
63
72
  let tailTimer = null;
64
73
  let scanTimer = null;
65
74
  let waitingPrinted = false;
66
75
  let completionBannerPrinted = false;
76
+ let sessionInfoPrinted = false;
77
+ let sessionRolloutPath = "";
78
+ let lastRolloutSearchAt = null;
67
79
 
68
80
  process.on("SIGINT", () => {
69
81
  clearTimers();
@@ -78,6 +90,7 @@ if (once) {
78
90
  process.exit(0);
79
91
  }
80
92
  printHeader(job);
93
+ printSessionInfo(job);
81
94
  renderWholeLog(job);
82
95
  process.exit(0);
83
96
  }
@@ -160,10 +173,13 @@ function findNewestJob() {
160
173
  jsonPath,
161
174
  logPath,
162
175
  createdAt,
163
- displayedCreatedAt: typeof meta.createdAt === "string" ? meta.createdAt : createdAt,
176
+ displayedCreatedAt: typeof meta.createdAt === "string" && meta.createdAt ? meta.createdAt : createdAt,
177
+ startedAt: typeof meta.startedAt === "string" ? meta.startedAt : "",
164
178
  status: typeof meta.status === "string" ? meta.status : "unknown",
165
179
  phase: typeof meta.phase === "string" ? meta.phase : "unknown",
166
- summary: typeof meta.summary === "string" ? meta.summary : ""
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
167
183
  });
168
184
  }
169
185
  }
@@ -194,28 +210,32 @@ function scanForNewJob() {
194
210
  }
195
211
 
196
212
  if (!currentJob) {
197
- switchToJob(newest, false);
213
+ switchToJob(newest);
198
214
  return;
199
215
  }
200
216
 
201
217
  if (newest.createdAt > currentJob.createdAt) {
202
218
  console.log(color("dim", "──────── switched to " + newest.id + " ────────"));
203
- switchToJob(newest, true);
219
+ switchToJob(newest);
204
220
  }
205
221
  }
206
222
 
207
223
  function switchToJob(job) {
208
224
  currentJob = job;
209
- currentKey = job.jsonPath;
210
225
  logOffset = 0;
226
+ logDecoder = new StringDecoder("utf8");
211
227
  pendingText = "";
212
228
  assistantBlock = false;
213
229
  waitingPrinted = false;
214
230
  completionBannerPrinted = false;
231
+ sessionInfoPrinted = false;
232
+ sessionRolloutPath = "";
233
+ lastRolloutSearchAt = null;
215
234
  if (tailTimer) {
216
235
  clearInterval(tailTimer);
217
236
  }
218
237
  printHeader(job);
238
+ printSessionInfo(job);
219
239
  readAvailableLog();
220
240
  tailTimer = setInterval(readAvailableLog, 400);
221
241
  }
@@ -235,6 +255,100 @@ function printHeader(job) {
235
255
  console.log(color("dim", line));
236
256
  }
237
257
 
258
+ function printSessionInfo(job) {
259
+ if (sessionInfoPrinted || !job.threadId) {
260
+ return;
261
+ }
262
+
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
+ }
273
+ }
274
+
275
+ const text = readFileText(sessionRolloutPath);
276
+ const startedAt = typeof job.startedAt === "string" && job.startedAt
277
+ ? job.startedAt
278
+ : typeof job.createdAt === "string"
279
+ ? job.createdAt
280
+ : "";
281
+ let turnContext = null;
282
+
283
+ for (const line of text.split(/\r?\n/)) {
284
+ try {
285
+ const entry = JSON.parse(line);
286
+ if (
287
+ entry &&
288
+ entry.type === "turn_context" &&
289
+ (!startedAt || (typeof entry.timestamp === "string" && entry.timestamp >= startedAt))
290
+ ) {
291
+ turnContext = entry;
292
+ }
293
+ } catch (_error) {}
294
+ }
295
+
296
+ const payload = turnContext && turnContext.payload;
297
+ if (!payload || typeof payload.model !== "string" || !payload.model) {
298
+ return;
299
+ }
300
+
301
+ const segments = ["model " + payload.model];
302
+ if (typeof payload.effort === "string" && payload.effort) {
303
+ segments.push("effort " + payload.effort);
304
+ }
305
+ if (payload.sandbox_policy && typeof payload.sandbox_policy.type === "string" && payload.sandbox_policy.type) {
306
+ segments.push("sandbox " + payload.sandbox_policy.type);
307
+ }
308
+ segments.push(job.write === true ? "write" : "read-only");
309
+ console.log(color("dim", segments.join(" · ")));
310
+ sessionInfoPrinted = true;
311
+ }
312
+
313
+ function findRolloutFile(threadId) {
314
+ const sessionsRoot = path.join(codexHome, "sessions");
315
+ const years = safeReadDir(sessionsRoot, { withFileTypes: true })
316
+ .filter((entry) => entry.isDirectory())
317
+ .map((entry) => entry.name)
318
+ .sort()
319
+ .reverse();
320
+
321
+ for (const year of years) {
322
+ const yearDir = path.join(sessionsRoot, year);
323
+ const months = safeReadDir(yearDir, { withFileTypes: true })
324
+ .filter((entry) => entry.isDirectory())
325
+ .map((entry) => entry.name)
326
+ .sort()
327
+ .reverse();
328
+
329
+ for (const month of months) {
330
+ const monthDir = path.join(yearDir, month);
331
+ const days = safeReadDir(monthDir, { withFileTypes: true })
332
+ .filter((entry) => entry.isDirectory())
333
+ .map((entry) => entry.name)
334
+ .sort()
335
+ .reverse();
336
+
337
+ for (const day of days) {
338
+ const dayDir = path.join(monthDir, day);
339
+ const rollout = safeReadDir(dayDir, { withFileTypes: true }).find(
340
+ (entry) => entry.isFile() && entry.name.endsWith("-" + threadId + ".jsonl")
341
+ );
342
+ if (rollout) {
343
+ return path.join(dayDir, rollout.name);
344
+ }
345
+ }
346
+ }
347
+ }
348
+
349
+ return "";
350
+ }
351
+
238
352
  function formatElapsed(createdAt, updatedAt) {
239
353
  const started = Date.parse(createdAt);
240
354
  if (!Number.isFinite(started)) {
@@ -261,35 +375,36 @@ function renderWholeLog(job) {
261
375
  }
262
376
 
263
377
  function readAvailableLog() {
264
- if (!currentJob || currentKey !== currentJob.jsonPath) {
265
- return;
266
- }
267
-
268
- if (!checkJobCompletion()) {
378
+ if (!currentJob) {
269
379
  return;
270
380
  }
271
381
 
272
382
  const stat = safeStat(currentJob.logPath);
273
- if (!stat || !stat.isFile()) {
274
- return;
275
- }
276
-
277
- if (stat.size < logOffset) {
278
- logOffset = 0;
279
- pendingText = "";
280
- assistantBlock = false;
281
- }
383
+ if (stat && stat.isFile()) {
384
+ if (stat.size < logOffset) {
385
+ logOffset = 0;
386
+ logDecoder = new StringDecoder("utf8");
387
+ pendingText = "";
388
+ assistantBlock = false;
389
+ }
282
390
 
283
- if (stat.size === logOffset) {
284
- return;
391
+ if (stat.size !== logOffset) {
392
+ const chunk = readFileChunk(currentJob.logPath, logOffset, stat.size - logOffset);
393
+ if (chunk !== null) {
394
+ logOffset += chunk.length;
395
+ renderText(logDecoder.write(chunk), true);
396
+ }
397
+ }
285
398
  }
286
399
 
287
- const chunk = readFileChunk(currentJob.logPath, logOffset, stat.size - logOffset);
288
- logOffset = stat.size;
289
- renderText(chunk, true);
400
+ checkJobCompletion();
290
401
  }
291
402
 
292
403
  function checkJobCompletion() {
404
+ if (completionBannerPrinted) {
405
+ return true;
406
+ }
407
+
293
408
  let meta = null;
294
409
  try {
295
410
  meta = JSON.parse(fs.readFileSync(currentJob.jsonPath, "utf8"));
@@ -298,13 +413,19 @@ function checkJobCompletion() {
298
413
  }
299
414
 
300
415
  const status = typeof meta.status === "string" ? meta.status : "unknown";
301
- if (completionBannerPrinted || !isTerminalStatus(status)) {
416
+ currentJob.threadId = typeof meta.threadId === "string" ? meta.threadId : "";
417
+ currentJob.write = typeof meta.write === "boolean" ? meta.write : false;
418
+ currentJob.startedAt = typeof meta.startedAt === "string" ? meta.startedAt : "";
419
+ printSessionInfo(currentJob);
420
+ if (!isTerminalStatus(status)) {
302
421
  return true;
303
422
  }
304
423
 
305
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;
306
426
  const updatedAt = typeof meta.updatedAt === "string" && meta.updatedAt ? meta.updatedAt : "";
307
- const elapsed = formatElapsed(createdAt, updatedAt);
427
+ const completedAt = typeof meta.completedAt === "string" && meta.completedAt ? meta.completedAt : updatedAt;
428
+ const elapsed = formatElapsed(startedAt, completedAt);
308
429
  if (status === "completed") {
309
430
  console.log(color("green", "✓ completed · " + elapsed));
310
431
  } else {
@@ -315,7 +436,7 @@ function checkJobCompletion() {
315
436
  }
316
437
 
317
438
  function isTerminalStatus(status) {
318
- return status !== "running" && status !== "queued" && status !== "pending";
439
+ return status !== "running" && status !== "queued";
319
440
  }
320
441
 
321
442
  function readFileText(file) {
@@ -332,16 +453,14 @@ function readFileChunk(file, start, length) {
332
453
  fd = fs.openSync(file, "r");
333
454
  const buffer = Buffer.alloc(length);
334
455
  const bytesRead = fs.readSync(fd, buffer, 0, length, start);
335
- return buffer.subarray(0, bytesRead).toString("utf8");
456
+ return buffer.subarray(0, bytesRead);
336
457
  } catch (_error) {
337
- return "";
458
+ return null;
338
459
  } finally {
339
460
  if (fd !== null) {
340
461
  try {
341
462
  fs.closeSync(fd);
342
- } catch (_error) {
343
- return "";
344
- }
463
+ } catch (_error) {}
345
464
  }
346
465
  }
347
466
  }
@@ -368,7 +487,7 @@ function renderText(text, keepPartial) {
368
487
  }
369
488
 
370
489
  function renderLine(line) {
371
- const match = line.match(/^\[[^\]]+\]\s+(.*)$/);
490
+ const match = line.match(/^\[\d{4}-\d{2}-\d{2}T[^\]]*\]\s+(.*)$/);
372
491
  if (!match) {
373
492
  if (assistantBlock) {
374
493
  console.log(assistantText(line));
@@ -427,8 +546,9 @@ function renderLine(line) {
427
546
 
428
547
  function truncate(value) {
429
548
  const maxLength = 140;
430
- if (value.length <= maxLength) {
549
+ const codePoints = Array.from(value);
550
+ if (codePoints.length <= maxLength) {
431
551
  return value;
432
552
  }
433
- return value.slice(0, maxLength - 1) + "…";
553
+ return codePoints.slice(0, maxLength - 1).join("") + "…";
434
554
  }
package/package.json CHANGED
@@ -1,8 +1,16 @@
1
1
  {
2
2
  "name": "@cubicj/codex-watch",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Live-tail the newest Codex plugin background job log for the current git repo.",
5
5
  "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/cubicj/codex-watch.git"
9
+ },
10
+ "bugs": {
11
+ "url": "https://github.com/cubicj/codex-watch/issues"
12
+ },
13
+ "homepage": "https://github.com/cubicj/codex-watch#readme",
6
14
  "publishConfig": {
7
15
  "access": "public"
8
16
  },