@cubicj/codex-watch 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 cubicj
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,77 @@
1
+ # codex-watch
2
+
3
+ Live-tail the newest Codex plugin background job log for the current git repo.
4
+
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
+
7
+ ## What It Does
8
+
9
+ - Resolves the git repo from the terminal's current working directory.
10
+ - Follows the newest job log written by the `openai/codex-plugin-cc` Claude Code plugin.
11
+ - Auto-switches when a `--resume` starts a newer job id, so a resumed run is picked up without restarting.
12
+ - Colorizes assistant messages, command status, file changes, and job events.
13
+ - Prints a completion banner such as `✓ completed · <duration>` or `✗ <status> · <duration>`.
14
+ - Drops duplicate captured lines.
15
+
16
+ ## Requirements
17
+
18
+ - Node.js 18 or newer.
19
+ - The [`openai/codex-plugin-cc`](https://github.com/openai/codex-plugin-cc) Claude Code plugin producing background job logs.
20
+
21
+ `codex-watch` reads job state from `$CLAUDE_PLUGIN_DATA/state/...` or, by default, `~/.claude/plugins/data/codex-openai-codex/state/`.
22
+
23
+ ## Install
24
+
25
+ Global install:
26
+
27
+ ```sh
28
+ npm install -g @cubicj/codex-watch
29
+ codex-watch
30
+ ```
31
+
32
+ From source:
33
+
34
+ ```sh
35
+ git clone <repo-url>
36
+ cd codex-watch
37
+ npm link
38
+ codex-watch
39
+ ```
40
+
41
+ You can also run it directly from the checkout:
42
+
43
+ ```sh
44
+ node bin/codex-watch
45
+ ```
46
+
47
+ Run `codex-watch` from inside the git repo whose Codex job you want to watch.
48
+
49
+ ## Usage
50
+
51
+ ```sh
52
+ codex-watch
53
+ codex-watch --once
54
+ codex-watch --help
55
+ ```
56
+
57
+ `codex-watch` starts in follow mode, tails the newest job, and keeps watching for newer jobs so a resumed Codex run switches automatically. `--once` renders the current newest job once and exits. `--help` prints usage.
58
+
59
+ Colors are enabled only on TTY output. Set `NO_COLOR=1` or pipe output to disable color.
60
+
61
+ ## Recommended Setup
62
+
63
+ `codex-watch` is most useful as a live cockpit for background delegation with `openai/codex-plugin-cc`:
64
+
65
+ 1. In Claude Code, delegate work to a Codex job in the background (for example a `--background` rescue or review) so Claude stays responsive.
66
+ 2. In a second terminal opened at the repo root, run `codex-watch`. It scopes to that repo, tails the job's log as it streams, and prints the completion banner when the job finishes.
67
+ 3. Steer by talking to Claude. If Claude cancels and resumes the job, `codex-watch` auto-switches to the new job id — no restart needed.
68
+
69
+ Because you invoke it once per session from the repo terminal, a short shell alias is handy if you reach for it often:
70
+
71
+ ```sh
72
+ alias cw='codex-watch'
73
+ ```
74
+
75
+ ## License
76
+
77
+ [MIT](LICENSE)
@@ -0,0 +1,434 @@
1
+ #!/usr/bin/env node
2
+ const fs = require("fs");
3
+ const path = require("path");
4
+ const os = require("os");
5
+ const childProcess = require("child_process");
6
+
7
+ const args = process.argv.slice(2);
8
+ const useColor = !process.env.NO_COLOR && process.stdout.isTTY;
9
+ const colors = {
10
+ reset: "\x1b[0m",
11
+ dim: "\x1b[2m",
12
+ gray: "\x1b[90m",
13
+ blue: "\x1b[34m",
14
+ cyan: "\x1b[36m",
15
+ green: "\x1b[32m",
16
+ red: "\x1b[31m",
17
+ yellow: "\x1b[33m",
18
+ assistantBg: "\x1b[48;5;236m"
19
+ };
20
+
21
+ function color(name, value) {
22
+ if (!useColor) {
23
+ return value;
24
+ }
25
+ return colors[name] + value + colors.reset;
26
+ }
27
+
28
+ function assistantText(value) {
29
+ if (!useColor) {
30
+ return value;
31
+ }
32
+ return colors.assistantBg + colors.cyan + value + "\x1b[K" + colors.reset;
33
+ }
34
+
35
+ function printUsage() {
36
+ console.log("Usage: codex-watch [--once] [--help]");
37
+ console.log("");
38
+ console.log("Live-tail the newest Codex companion job log for the current git repo.");
39
+ console.log("");
40
+ console.log("Options:");
41
+ console.log(" --once Render the current newest job once and exit");
42
+ console.log(" -h, --help Show this help");
43
+ }
44
+
45
+ if (args.includes("--help") || args.includes("-h")) {
46
+ printUsage();
47
+ process.exit(0);
48
+ }
49
+
50
+ const once = args.includes("--once");
51
+ const repoRoot = resolveRepoRoot();
52
+ const slug = makeSlug(repoRoot);
53
+ const stateRoot = process.env.CLAUDE_PLUGIN_DATA
54
+ ? path.join(process.env.CLAUDE_PLUGIN_DATA, "state")
55
+ : path.join(os.homedir(), ".claude/plugins/data/codex-openai-codex/state");
56
+ const stateDirPattern = new RegExp("^" + escapeRegExp(slug) + "-[0-9a-f]{6,}$");
57
+
58
+ let currentJob = null;
59
+ let currentKey = "";
60
+ let logOffset = 0;
61
+ let pendingText = "";
62
+ let assistantBlock = false;
63
+ let tailTimer = null;
64
+ let scanTimer = null;
65
+ let waitingPrinted = false;
66
+ let completionBannerPrinted = false;
67
+
68
+ process.on("SIGINT", () => {
69
+ clearTimers();
70
+ process.stdout.write("\n");
71
+ process.exit(0);
72
+ });
73
+
74
+ if (once) {
75
+ const job = findNewestJob();
76
+ if (!job) {
77
+ console.log("No Codex job found for " + slug + ".");
78
+ process.exit(0);
79
+ }
80
+ printHeader(job);
81
+ renderWholeLog(job);
82
+ process.exit(0);
83
+ }
84
+
85
+ startFollow();
86
+
87
+ function resolveRepoRoot() {
88
+ try {
89
+ const output = childProcess.execFileSync("git", ["rev-parse", "--show-toplevel"], {
90
+ cwd: process.cwd(),
91
+ encoding: "utf8",
92
+ stdio: ["ignore", "pipe", "ignore"]
93
+ });
94
+ const trimmed = output.trim();
95
+ return trimmed || process.cwd();
96
+ } catch (_error) {
97
+ return process.cwd();
98
+ }
99
+ }
100
+
101
+ function makeSlug(root) {
102
+ return path.basename(root).replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "") || "workspace";
103
+ }
104
+
105
+ function escapeRegExp(value) {
106
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
107
+ }
108
+
109
+ function safeReadDir(dir, options) {
110
+ try {
111
+ return fs.readdirSync(dir, options);
112
+ } catch (_error) {
113
+ return [];
114
+ }
115
+ }
116
+
117
+ function safeStat(file) {
118
+ try {
119
+ return fs.statSync(file);
120
+ } catch (_error) {
121
+ return null;
122
+ }
123
+ }
124
+
125
+ function readJson(file) {
126
+ try {
127
+ return JSON.parse(fs.readFileSync(file, "utf8"));
128
+ } catch (_error) {
129
+ return {};
130
+ }
131
+ }
132
+
133
+ function findNewestJob() {
134
+ const stateEntries = safeReadDir(stateRoot, { withFileTypes: true });
135
+ const candidates = [];
136
+
137
+ for (const entry of stateEntries) {
138
+ if (!entry.isDirectory() || !stateDirPattern.test(entry.name)) {
139
+ continue;
140
+ }
141
+
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
+ }
169
+ }
170
+
171
+ candidates.sort((a, b) => {
172
+ if (a.createdAt === b.createdAt) {
173
+ return a.id.localeCompare(b.id);
174
+ }
175
+ return a.createdAt < b.createdAt ? 1 : -1;
176
+ });
177
+
178
+ return candidates[0] || null;
179
+ }
180
+
181
+ function startFollow() {
182
+ scanTimer = setInterval(scanForNewJob, 1000);
183
+ scanForNewJob();
184
+ }
185
+
186
+ function scanForNewJob() {
187
+ const newest = findNewestJob();
188
+ if (!newest) {
189
+ if (!waitingPrinted) {
190
+ console.log("waiting for a Codex job in " + slug + "…");
191
+ waitingPrinted = true;
192
+ }
193
+ return;
194
+ }
195
+
196
+ if (!currentJob) {
197
+ switchToJob(newest, false);
198
+ return;
199
+ }
200
+
201
+ if (newest.createdAt > currentJob.createdAt) {
202
+ console.log(color("dim", "──────── switched to " + newest.id + " ────────"));
203
+ switchToJob(newest, true);
204
+ }
205
+ }
206
+
207
+ function switchToJob(job) {
208
+ currentJob = job;
209
+ currentKey = job.jsonPath;
210
+ logOffset = 0;
211
+ pendingText = "";
212
+ assistantBlock = false;
213
+ waitingPrinted = false;
214
+ completionBannerPrinted = false;
215
+ if (tailTimer) {
216
+ clearInterval(tailTimer);
217
+ }
218
+ printHeader(job);
219
+ readAvailableLog();
220
+ tailTimer = setInterval(readAvailableLog, 400);
221
+ }
222
+
223
+ function clearTimers() {
224
+ if (scanTimer) {
225
+ clearInterval(scanTimer);
226
+ }
227
+ if (tailTimer) {
228
+ clearInterval(tailTimer);
229
+ }
230
+ }
231
+
232
+ function printHeader(job) {
233
+ const elapsed = formatElapsed(job.displayedCreatedAt);
234
+ const line = "job " + job.id + " | status " + job.status + " | phase " + job.phase + " | elapsed " + elapsed;
235
+ console.log(color("dim", line));
236
+ }
237
+
238
+ function formatElapsed(createdAt, updatedAt) {
239
+ const started = Date.parse(createdAt);
240
+ if (!Number.isFinite(started)) {
241
+ return "unknown";
242
+ }
243
+ const ended = updatedAt ? Date.parse(updatedAt) : Date.now();
244
+ const elapsedUntil = Number.isFinite(ended) ? ended : Date.now();
245
+ const seconds = Math.max(0, Math.floor((elapsedUntil - started) / 1000));
246
+ const hours = Math.floor(seconds / 3600);
247
+ const minutes = Math.floor((seconds % 3600) / 60);
248
+ const remainingSeconds = seconds % 60;
249
+ if (hours > 0) {
250
+ return hours + "h " + minutes + "m " + remainingSeconds + "s";
251
+ }
252
+ if (minutes > 0) {
253
+ return minutes + "m " + remainingSeconds + "s";
254
+ }
255
+ return remainingSeconds + "s";
256
+ }
257
+
258
+ function renderWholeLog(job) {
259
+ const text = readFileText(job.logPath);
260
+ renderText(text);
261
+ }
262
+
263
+ function readAvailableLog() {
264
+ if (!currentJob || currentKey !== currentJob.jsonPath) {
265
+ return;
266
+ }
267
+
268
+ if (!checkJobCompletion()) {
269
+ return;
270
+ }
271
+
272
+ 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
+ }
282
+
283
+ if (stat.size === logOffset) {
284
+ return;
285
+ }
286
+
287
+ const chunk = readFileChunk(currentJob.logPath, logOffset, stat.size - logOffset);
288
+ logOffset = stat.size;
289
+ renderText(chunk, true);
290
+ }
291
+
292
+ function checkJobCompletion() {
293
+ let meta = null;
294
+ try {
295
+ meta = JSON.parse(fs.readFileSync(currentJob.jsonPath, "utf8"));
296
+ } catch (_error) {
297
+ return false;
298
+ }
299
+
300
+ const status = typeof meta.status === "string" ? meta.status : "unknown";
301
+ if (completionBannerPrinted || !isTerminalStatus(status)) {
302
+ return true;
303
+ }
304
+
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);
308
+ if (status === "completed") {
309
+ console.log(color("green", "✓ completed · " + elapsed));
310
+ } else {
311
+ console.log(color("red", "✗ " + status + " · " + elapsed));
312
+ }
313
+ completionBannerPrinted = true;
314
+ return true;
315
+ }
316
+
317
+ function isTerminalStatus(status) {
318
+ return status !== "running" && status !== "queued" && status !== "pending";
319
+ }
320
+
321
+ function readFileText(file) {
322
+ try {
323
+ return fs.readFileSync(file, "utf8");
324
+ } catch (_error) {
325
+ return "";
326
+ }
327
+ }
328
+
329
+ function readFileChunk(file, start, length) {
330
+ let fd = null;
331
+ try {
332
+ fd = fs.openSync(file, "r");
333
+ const buffer = Buffer.alloc(length);
334
+ const bytesRead = fs.readSync(fd, buffer, 0, length, start);
335
+ return buffer.subarray(0, bytesRead).toString("utf8");
336
+ } catch (_error) {
337
+ return "";
338
+ } finally {
339
+ if (fd !== null) {
340
+ try {
341
+ fs.closeSync(fd);
342
+ } catch (_error) {
343
+ return "";
344
+ }
345
+ }
346
+ }
347
+ }
348
+
349
+ function renderText(text, keepPartial) {
350
+ if (!text) {
351
+ return;
352
+ }
353
+
354
+ const combined = pendingText + text;
355
+ const endsWithNewline = /\r?\n$/.test(combined);
356
+ const lines = combined.split(/\r?\n/);
357
+ pendingText = "";
358
+
359
+ if (!endsWithNewline && keepPartial) {
360
+ pendingText = lines.pop() || "";
361
+ } else if (endsWithNewline) {
362
+ lines.pop();
363
+ }
364
+
365
+ for (const line of lines) {
366
+ renderLine(line);
367
+ }
368
+ }
369
+
370
+ function renderLine(line) {
371
+ const match = line.match(/^\[[^\]]+\]\s+(.*)$/);
372
+ if (!match) {
373
+ if (assistantBlock) {
374
+ console.log(assistantText(line));
375
+ } else {
376
+ console.log(line);
377
+ }
378
+ return;
379
+ }
380
+
381
+ const event = match[1];
382
+
383
+ if (event === "Assistant message") {
384
+ assistantBlock = true;
385
+ return;
386
+ }
387
+
388
+ assistantBlock = false;
389
+
390
+ if (event.startsWith("Assistant message captured:")) {
391
+ return;
392
+ }
393
+
394
+ if (event.startsWith("Running command: ")) {
395
+ console.log(color("dim", color("blue", "Running command: " + truncate(event.slice("Running command: ".length)))));
396
+ return;
397
+ }
398
+
399
+ const completed = event.match(/^Command completed: (.*) \(exit ([0-9]+)\)$/);
400
+ if (completed) {
401
+ const message = "Command completed: " + truncate(completed[1]) + " (exit " + completed[2] + ")";
402
+ if (completed[2] === "0") {
403
+ console.log(color("green", "✓ " + message));
404
+ } else {
405
+ console.log(color("red", "✗ " + message));
406
+ }
407
+ return;
408
+ }
409
+
410
+ if (event.startsWith("Command failed")) {
411
+ console.log(color("red", "✗ " + event));
412
+ return;
413
+ }
414
+
415
+ if (event.startsWith("File changes") || /^Applying [0-9]+ file change\(s\)\.$/.test(event) || event === "File changes completed.") {
416
+ console.log(color("yellow", event));
417
+ return;
418
+ }
419
+
420
+ if (/^(Turn started|Turn completed|Thread ready|Final output)\b/.test(event)) {
421
+ console.log(color("gray", event));
422
+ return;
423
+ }
424
+
425
+ console.log(event);
426
+ }
427
+
428
+ function truncate(value) {
429
+ const maxLength = 140;
430
+ if (value.length <= maxLength) {
431
+ return value;
432
+ }
433
+ return value.slice(0, maxLength - 1) + "…";
434
+ }
package/package.json ADDED
@@ -0,0 +1,26 @@
1
+ {
2
+ "name": "@cubicj/codex-watch",
3
+ "version": "0.1.0",
4
+ "description": "Live-tail the newest Codex plugin background job log for the current git repo.",
5
+ "license": "MIT",
6
+ "publishConfig": {
7
+ "access": "public"
8
+ },
9
+ "bin": {
10
+ "codex-watch": "bin/codex-watch"
11
+ },
12
+ "scripts": {
13
+ "test": "node --test"
14
+ },
15
+ "engines": {
16
+ "node": ">=18"
17
+ },
18
+ "files": [
19
+ "bin"
20
+ ],
21
+ "keywords": [
22
+ "codex",
23
+ "claude-code",
24
+ "cli"
25
+ ]
26
+ }