@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 +2 -1
- package/bin/codex-watch +205 -75
- package/package.json +9 -1
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
|
|
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
|
|
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
|
|
106
|
-
|
|
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
|
|
156
|
+
const jobsDir = path.join(stateRoot, stateDirName, "jobs");
|
|
157
|
+
const jobEntries = safeReadDir(jobsDir, { withFileTypes: true });
|
|
135
158
|
const candidates = [];
|
|
136
159
|
|
|
137
|
-
for (const
|
|
138
|
-
if (!
|
|
160
|
+
for (const jobEntry of jobEntries) {
|
|
161
|
+
if (!jobEntry.isFile() || !jobEntry.name.endsWith(".json")) {
|
|
139
162
|
continue;
|
|
140
163
|
}
|
|
141
164
|
|
|
142
|
-
const
|
|
143
|
-
const
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
|
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
|
|
216
|
+
switchToJob(newest);
|
|
198
217
|
return;
|
|
199
218
|
}
|
|
200
219
|
|
|
201
|
-
if (
|
|
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
|
|
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
|
|
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 (
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
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
|
-
|
|
284
|
-
|
|
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
|
-
|
|
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
|
|
418
|
+
return;
|
|
298
419
|
}
|
|
299
420
|
|
|
300
421
|
const status = typeof meta.status === "string" ? meta.status : "unknown";
|
|
301
|
-
|
|
302
|
-
|
|
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
|
-
|
|
306
|
-
|
|
307
|
-
|
|
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"
|
|
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)
|
|
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(/^\[[^\]]
|
|
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
|
-
|
|
559
|
+
const codePoints = Array.from(value);
|
|
560
|
+
if (codePoints.length <= maxLength) {
|
|
431
561
|
return value;
|
|
432
562
|
}
|
|
433
|
-
return
|
|
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
|
|
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
|
},
|