@cubicj/codex-watch 0.1.0 → 0.2.1

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,15 @@ 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");
7
+ const { StringDecoder } = require("node:string_decoder");
8
+
9
+ process.stdout.on("error", (error) => {
10
+ if (error.code === "EPIPE") {
11
+ process.exit(0);
12
+ }
13
+ throw error;
14
+ });
6
15
 
7
16
  const args = process.argv.slice(2);
8
17
  const useColor = !process.env.NO_COLOR && process.stdout.isTTY;
@@ -50,20 +59,24 @@ if (args.includes("--help") || args.includes("-h")) {
50
59
  const once = args.includes("--once");
51
60
  const repoRoot = resolveRepoRoot();
52
61
  const slug = makeSlug(repoRoot);
62
+ const stateDirName = makeStateDirName(repoRoot, slug);
53
63
  const stateRoot = process.env.CLAUDE_PLUGIN_DATA
54
64
  ? path.join(process.env.CLAUDE_PLUGIN_DATA, "state")
55
65
  : path.join(os.homedir(), ".claude/plugins/data/codex-openai-codex/state");
56
- const stateDirPattern = new RegExp("^" + escapeRegExp(slug) + "-[0-9a-f]{6,}$");
66
+ const codexHome = process.env.CODEX_HOME || path.join(os.homedir(), ".codex");
57
67
 
58
68
  let currentJob = null;
59
- let currentKey = "";
60
69
  let logOffset = 0;
70
+ let logDecoder = new StringDecoder("utf8");
61
71
  let pendingText = "";
62
72
  let assistantBlock = false;
63
73
  let tailTimer = null;
64
74
  let scanTimer = null;
65
75
  let waitingPrinted = false;
66
76
  let completionBannerPrinted = false;
77
+ let sessionInfoPrinted = false;
78
+ let sessionRolloutPath = "";
79
+ let lastRolloutSearchAt = null;
67
80
 
68
81
  process.on("SIGINT", () => {
69
82
  clearTimers();
@@ -78,7 +91,11 @@ if (once) {
78
91
  process.exit(0);
79
92
  }
80
93
  printHeader(job);
94
+ printSessionInfo(job);
81
95
  renderWholeLog(job);
96
+ if (isTerminalStatus(job.status)) {
97
+ printCompletionBanner(job, job.displayedCreatedAt);
98
+ }
82
99
  process.exit(0);
83
100
  }
84
101
 
@@ -102,8 +119,13 @@ function makeSlug(root) {
102
119
  return path.basename(root).replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "") || "workspace";
103
120
  }
104
121
 
105
- function escapeRegExp(value) {
106
- return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
122
+ function makeStateDirName(root, rootSlug) {
123
+ let canonical = root;
124
+ try {
125
+ canonical = fs.realpathSync.native(root);
126
+ } catch (_error) {}
127
+ const hash = crypto.createHash("sha256").update(canonical).digest("hex").slice(0, 16);
128
+ return rootSlug + "-" + hash;
107
129
  }
108
130
 
109
131
  function safeReadDir(dir, options) {
@@ -131,46 +153,43 @@ function readJson(file) {
131
153
  }
132
154
 
133
155
  function findNewestJob() {
134
- const stateEntries = safeReadDir(stateRoot, { withFileTypes: true });
156
+ const jobsDir = path.join(stateRoot, stateDirName, "jobs");
157
+ const jobEntries = safeReadDir(jobsDir, { withFileTypes: true });
135
158
  const candidates = [];
136
159
 
137
- for (const entry of stateEntries) {
138
- if (!entry.isDirectory() || !stateDirPattern.test(entry.name)) {
160
+ for (const jobEntry of jobEntries) {
161
+ if (!jobEntry.isFile() || !jobEntry.name.endsWith(".json")) {
139
162
  continue;
140
163
  }
141
164
 
142
- const jobsDir = path.join(stateRoot, entry.name, "jobs");
143
- const jobEntries = safeReadDir(jobsDir, { withFileTypes: true });
144
-
145
- for (const jobEntry of jobEntries) {
146
- if (!jobEntry.isFile() || !jobEntry.name.endsWith(".json")) {
147
- continue;
148
- }
149
-
150
- const id = path.basename(jobEntry.name, ".json");
151
- const jsonPath = path.join(jobsDir, jobEntry.name);
152
- const logPath = path.join(jobsDir, id + ".log");
153
- const meta = readJson(jsonPath);
154
- const stat = safeStat(jsonPath);
155
- const fallbackCreatedAt = stat ? stat.mtime.toISOString() : "";
156
- const createdAt = typeof meta.createdAt === "string" && meta.createdAt ? meta.createdAt : fallbackCreatedAt;
157
-
158
- candidates.push({
159
- id,
160
- jsonPath,
161
- logPath,
162
- createdAt,
163
- displayedCreatedAt: typeof meta.createdAt === "string" ? meta.createdAt : createdAt,
164
- status: typeof meta.status === "string" ? meta.status : "unknown",
165
- phase: typeof meta.phase === "string" ? meta.phase : "unknown",
166
- summary: typeof meta.summary === "string" ? meta.summary : ""
167
- });
168
- }
165
+ const id = path.basename(jobEntry.name, ".json");
166
+ const jsonPath = path.join(jobsDir, jobEntry.name);
167
+ const logPath = path.join(jobsDir, id + ".log");
168
+ const meta = readJson(jsonPath);
169
+ const stat = safeStat(jsonPath);
170
+ const fallbackCreatedAt = stat ? stat.mtime.toISOString() : "";
171
+ const createdAt = typeof meta.createdAt === "string" && meta.createdAt ? meta.createdAt : fallbackCreatedAt;
172
+
173
+ candidates.push({
174
+ id,
175
+ jsonPath,
176
+ logPath,
177
+ createdAt,
178
+ displayedCreatedAt: typeof meta.createdAt === "string" && meta.createdAt ? meta.createdAt : createdAt,
179
+ startedAt: typeof meta.startedAt === "string" ? meta.startedAt : "",
180
+ updatedAt: typeof meta.updatedAt === "string" ? meta.updatedAt : "",
181
+ completedAt: typeof meta.completedAt === "string" ? meta.completedAt : "",
182
+ status: typeof meta.status === "string" ? meta.status : "unknown",
183
+ phase: typeof meta.phase === "string" ? meta.phase : "unknown",
184
+ summary: typeof meta.summary === "string" ? meta.summary : "",
185
+ threadId: typeof meta.threadId === "string" ? meta.threadId : "",
186
+ write: typeof meta.write === "boolean" ? meta.write : false
187
+ });
169
188
  }
170
189
 
171
190
  candidates.sort((a, b) => {
172
191
  if (a.createdAt === b.createdAt) {
173
- return a.id.localeCompare(b.id);
192
+ return b.id.localeCompare(a.id);
174
193
  }
175
194
  return a.createdAt < b.createdAt ? 1 : -1;
176
195
  });
@@ -194,28 +213,35 @@ function scanForNewJob() {
194
213
  }
195
214
 
196
215
  if (!currentJob) {
197
- switchToJob(newest, false);
216
+ switchToJob(newest);
198
217
  return;
199
218
  }
200
219
 
201
- if (newest.createdAt > currentJob.createdAt) {
220
+ if (
221
+ newest.createdAt > currentJob.createdAt ||
222
+ (newest.createdAt === currentJob.createdAt && newest.id.localeCompare(currentJob.id) > 0)
223
+ ) {
202
224
  console.log(color("dim", "──────── switched to " + newest.id + " ────────"));
203
- switchToJob(newest, true);
225
+ switchToJob(newest);
204
226
  }
205
227
  }
206
228
 
207
229
  function switchToJob(job) {
208
230
  currentJob = job;
209
- currentKey = job.jsonPath;
210
231
  logOffset = 0;
232
+ logDecoder = new StringDecoder("utf8");
211
233
  pendingText = "";
212
234
  assistantBlock = false;
213
235
  waitingPrinted = false;
214
236
  completionBannerPrinted = false;
237
+ sessionInfoPrinted = false;
238
+ sessionRolloutPath = "";
239
+ lastRolloutSearchAt = null;
215
240
  if (tailTimer) {
216
241
  clearInterval(tailTimer);
217
242
  }
218
243
  printHeader(job);
244
+ printSessionInfo(job);
219
245
  readAvailableLog();
220
246
  tailTimer = setInterval(readAvailableLog, 400);
221
247
  }
@@ -235,6 +261,100 @@ function printHeader(job) {
235
261
  console.log(color("dim", line));
236
262
  }
237
263
 
264
+ function printSessionInfo(job) {
265
+ if (sessionInfoPrinted || !job.threadId) {
266
+ return;
267
+ }
268
+
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
+ }
279
+ }
280
+
281
+ const text = readFileText(sessionRolloutPath);
282
+ const startedAt = typeof job.startedAt === "string" && job.startedAt
283
+ ? job.startedAt
284
+ : typeof job.createdAt === "string"
285
+ ? job.createdAt
286
+ : "";
287
+ let turnContext = null;
288
+
289
+ for (const line of text.split(/\r?\n/)) {
290
+ try {
291
+ const entry = JSON.parse(line);
292
+ if (
293
+ entry &&
294
+ entry.type === "turn_context" &&
295
+ (!startedAt || (typeof entry.timestamp === "string" && entry.timestamp >= startedAt))
296
+ ) {
297
+ turnContext = entry;
298
+ }
299
+ } catch (_error) {}
300
+ }
301
+
302
+ const payload = turnContext && turnContext.payload;
303
+ if (!payload || typeof payload.model !== "string" || !payload.model) {
304
+ return;
305
+ }
306
+
307
+ const segments = ["model " + payload.model];
308
+ if (typeof payload.effort === "string" && payload.effort) {
309
+ segments.push("effort " + payload.effort);
310
+ }
311
+ if (payload.sandbox_policy && typeof payload.sandbox_policy.type === "string" && payload.sandbox_policy.type) {
312
+ segments.push("sandbox " + payload.sandbox_policy.type);
313
+ }
314
+ segments.push(job.write === true ? "write" : "read-only");
315
+ console.log(color("dim", segments.join(" · ")));
316
+ sessionInfoPrinted = true;
317
+ }
318
+
319
+ function findRolloutFile(threadId) {
320
+ const sessionsRoot = path.join(codexHome, "sessions");
321
+ const years = safeReadDir(sessionsRoot, { withFileTypes: true })
322
+ .filter((entry) => entry.isDirectory())
323
+ .map((entry) => entry.name)
324
+ .sort()
325
+ .reverse();
326
+
327
+ for (const year of years) {
328
+ const yearDir = path.join(sessionsRoot, year);
329
+ const months = safeReadDir(yearDir, { withFileTypes: true })
330
+ .filter((entry) => entry.isDirectory())
331
+ .map((entry) => entry.name)
332
+ .sort()
333
+ .reverse();
334
+
335
+ for (const month of months) {
336
+ const monthDir = path.join(yearDir, month);
337
+ const days = safeReadDir(monthDir, { withFileTypes: true })
338
+ .filter((entry) => entry.isDirectory())
339
+ .map((entry) => entry.name)
340
+ .sort()
341
+ .reverse();
342
+
343
+ for (const day of days) {
344
+ const dayDir = path.join(monthDir, day);
345
+ const rollout = safeReadDir(dayDir, { withFileTypes: true }).find(
346
+ (entry) => entry.isFile() && entry.name.endsWith("-" + threadId + ".jsonl")
347
+ );
348
+ if (rollout) {
349
+ return path.join(dayDir, rollout.name);
350
+ }
351
+ }
352
+ }
353
+ }
354
+
355
+ return "";
356
+ }
357
+
238
358
  function formatElapsed(createdAt, updatedAt) {
239
359
  const started = Date.parse(createdAt);
240
360
  if (!Number.isFinite(started)) {
@@ -261,61 +381,72 @@ function renderWholeLog(job) {
261
381
  }
262
382
 
263
383
  function readAvailableLog() {
264
- if (!currentJob || currentKey !== currentJob.jsonPath) {
265
- return;
266
- }
267
-
268
- if (!checkJobCompletion()) {
384
+ if (!currentJob) {
269
385
  return;
270
386
  }
271
387
 
272
388
  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
- }
389
+ if (stat && stat.isFile()) {
390
+ if (stat.size < logOffset) {
391
+ logOffset = 0;
392
+ logDecoder = new StringDecoder("utf8");
393
+ pendingText = "";
394
+ assistantBlock = false;
395
+ }
282
396
 
283
- if (stat.size === logOffset) {
284
- return;
397
+ if (stat.size !== logOffset) {
398
+ const chunk = readFileChunk(currentJob.logPath, logOffset, stat.size - logOffset);
399
+ if (chunk !== null) {
400
+ logOffset += chunk.length;
401
+ renderText(logDecoder.write(chunk), true);
402
+ }
403
+ }
285
404
  }
286
405
 
287
- const chunk = readFileChunk(currentJob.logPath, logOffset, stat.size - logOffset);
288
- logOffset = stat.size;
289
- renderText(chunk, true);
406
+ checkJobCompletion();
290
407
  }
291
408
 
292
409
  function checkJobCompletion() {
410
+ if (completionBannerPrinted) {
411
+ return;
412
+ }
413
+
293
414
  let meta = null;
294
415
  try {
295
416
  meta = JSON.parse(fs.readFileSync(currentJob.jsonPath, "utf8"));
296
417
  } catch (_error) {
297
- return false;
418
+ return;
298
419
  }
299
420
 
300
421
  const status = typeof meta.status === "string" ? meta.status : "unknown";
301
- if (completionBannerPrinted || !isTerminalStatus(status)) {
302
- return true;
422
+ currentJob.threadId = typeof meta.threadId === "string" ? meta.threadId : "";
423
+ currentJob.write = typeof meta.write === "boolean" ? meta.write : false;
424
+ currentJob.startedAt = typeof meta.startedAt === "string" ? meta.startedAt : "";
425
+ printSessionInfo(currentJob);
426
+ if (!isTerminalStatus(status)) {
427
+ return;
303
428
  }
304
429
 
305
- const createdAt = typeof meta.createdAt === "string" && meta.createdAt ? meta.createdAt : currentJob.displayedCreatedAt;
306
- const updatedAt = typeof meta.updatedAt === "string" && meta.updatedAt ? meta.updatedAt : "";
307
- const elapsed = formatElapsed(createdAt, updatedAt);
430
+ printCompletionBanner(meta, currentJob.displayedCreatedAt);
431
+ completionBannerPrinted = true;
432
+ }
433
+
434
+ function printCompletionBanner(job, fallbackCreatedAt) {
435
+ const status = typeof job.status === "string" ? job.status : "unknown";
436
+ const createdAt = typeof job.createdAt === "string" && job.createdAt ? job.createdAt : fallbackCreatedAt;
437
+ const startedAt = typeof job.startedAt === "string" && job.startedAt ? job.startedAt : createdAt;
438
+ const updatedAt = typeof job.updatedAt === "string" && job.updatedAt ? job.updatedAt : "";
439
+ const completedAt = typeof job.completedAt === "string" && job.completedAt ? job.completedAt : updatedAt;
440
+ const elapsed = formatElapsed(startedAt, completedAt);
308
441
  if (status === "completed") {
309
442
  console.log(color("green", "✓ completed · " + elapsed));
310
443
  } else {
311
444
  console.log(color("red", "✗ " + status + " · " + elapsed));
312
445
  }
313
- completionBannerPrinted = true;
314
- return true;
315
446
  }
316
447
 
317
448
  function isTerminalStatus(status) {
318
- return status !== "running" && status !== "queued" && status !== "pending";
449
+ return status !== "running" && status !== "queued";
319
450
  }
320
451
 
321
452
  function readFileText(file) {
@@ -332,16 +463,14 @@ function readFileChunk(file, start, length) {
332
463
  fd = fs.openSync(file, "r");
333
464
  const buffer = Buffer.alloc(length);
334
465
  const bytesRead = fs.readSync(fd, buffer, 0, length, start);
335
- return buffer.subarray(0, bytesRead).toString("utf8");
466
+ return buffer.subarray(0, bytesRead);
336
467
  } catch (_error) {
337
- return "";
468
+ return null;
338
469
  } finally {
339
470
  if (fd !== null) {
340
471
  try {
341
472
  fs.closeSync(fd);
342
- } catch (_error) {
343
- return "";
344
- }
473
+ } catch (_error) {}
345
474
  }
346
475
  }
347
476
  }
@@ -368,7 +497,7 @@ function renderText(text, keepPartial) {
368
497
  }
369
498
 
370
499
  function renderLine(line) {
371
- const match = line.match(/^\[[^\]]+\]\s+(.*)$/);
500
+ const match = line.match(/^\[\d{4}-\d{2}-\d{2}T[^\]]*\]\s+(.*)$/);
372
501
  if (!match) {
373
502
  if (assistantBlock) {
374
503
  console.log(assistantText(line));
@@ -427,8 +556,9 @@ function renderLine(line) {
427
556
 
428
557
  function truncate(value) {
429
558
  const maxLength = 140;
430
- if (value.length <= maxLength) {
559
+ const codePoints = Array.from(value);
560
+ if (codePoints.length <= maxLength) {
431
561
  return value;
432
562
  }
433
- return value.slice(0, maxLength - 1) + "…";
563
+ return codePoints.slice(0, maxLength - 1).join("") + "…";
434
564
  }
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.1",
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
  },