agent-dag 1.35.2 → 1.35.4
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/dist/web/assets/{index-B4-a3WNi.js → index-Z6DVMogL.js} +2 -2
- package/dist/web/index.html +1 -1
- package/package.json +1 -1
- package/src/server/codex-auth.mjs +11 -3
- package/src/server/codex-dir.mjs +171 -0
- package/src/server/codex-quota.mjs +5 -2
- package/src/server/codex-usage.mjs +24 -31
- package/src/server/index.mjs +110 -56
- package/src/server/installer.mjs +6 -5
package/dist/web/index.html
CHANGED
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
document.documentElement.setAttribute("data-theme", stored === "light" ? "light" : "dark");
|
|
41
41
|
})();
|
|
42
42
|
</script>
|
|
43
|
-
<script type="module" crossorigin src="/assets/index-
|
|
43
|
+
<script type="module" crossorigin src="/assets/index-Z6DVMogL.js"></script>
|
|
44
44
|
<link rel="stylesheet" crossorigin href="/assets/index-FKxgHN0p.css">
|
|
45
45
|
</head>
|
|
46
46
|
<body>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-dag",
|
|
3
|
-
"version": "1.35.
|
|
3
|
+
"version": "1.35.4",
|
|
4
4
|
"description": "Live deck of Claude Code and Codex agents — watch parallel subagents fork, call tools, and return on one calm canvas. Also available as npx ccdeck and npx agent-dag.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -14,12 +14,20 @@
|
|
|
14
14
|
// a rejected promise from a background poll would take the server down.
|
|
15
15
|
import { readFile, chmod, unlink, realpath } from "node:fs/promises";
|
|
16
16
|
import { join } from "node:path";
|
|
17
|
-
import {
|
|
17
|
+
import { CODEX_HOME } from "./codex-dir.mjs";
|
|
18
18
|
import { createTemp, renameWithRetry } from "./installer.mjs";
|
|
19
19
|
import { PRODUCT } from "./brand.mjs";
|
|
20
20
|
|
|
21
|
-
|
|
22
|
-
|
|
21
|
+
// This file used to resolve CODEX_HOME itself, as `process.env.CODEX_HOME ??
|
|
22
|
+
// join(homedir(), ".codex")`. `??` falls back on null and undefined only, so an
|
|
23
|
+
// empty CODEX_HOME — what `export CODEX_HOME=$SOME_UNSET_VAR` leaves in a
|
|
24
|
+
// profile — survived it, and join("", "auth.json") is the CWD-relative
|
|
25
|
+
// "auth.json". Of all five readers this was the expensive one to get wrong: the
|
|
26
|
+
// rotated refresh token below is single-use, so a write that lands in whatever
|
|
27
|
+
// directory the deck was started from does not lose a read, it burns the
|
|
28
|
+
// credential and costs the user a `codex login`. codex-dir.mjs owns the rule now
|
|
29
|
+
// and treats an empty value as "not set" (#375).
|
|
30
|
+
const AUTH_PATH = join(CODEX_HOME, "auth.json");
|
|
23
31
|
|
|
24
32
|
// Same client id + endpoint the Codex CLI uses (codex-rs/login/src/auth/manager.rs).
|
|
25
33
|
const CLIENT_ID = process.env.CODEX_APP_SERVER_LOGIN_CLIENT_ID ?? "app_EMoamEEZ73f0CkXaXp7hrann";
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
// Where OpenAI Codex lives on this machine: its home directory, its rollout
|
|
2
|
+
// tree, and the walk over that tree — the Codex-side mirror of claude-dir.mjs.
|
|
3
|
+
//
|
|
4
|
+
// CODEX_HOME relocates ~/.codex wholesale, exactly as CLAUDE_CONFIG_DIR does on
|
|
5
|
+
// the Claude side, and five modules used to answer the question for themselves
|
|
6
|
+
// in three different spellings (#375):
|
|
7
|
+
//
|
|
8
|
+
// index.mjs CODEX_HOME ? resolve(CODEX_HOME) : join(homedir(), ".codex")
|
|
9
|
+
// installer.mjs the same
|
|
10
|
+
// codex-usage.mjs CODEX_HOME ? CODEX_HOME : join(homedir(), ".codex")
|
|
11
|
+
// codex-auth.mjs CODEX_HOME ?? join(homedir(), ".codex")
|
|
12
|
+
// codex-quota.mjs the same
|
|
13
|
+
//
|
|
14
|
+
// The three spellings disagree on two inputs that a shell profile produces by
|
|
15
|
+
// accident rather than by intent, and both disagreements are silent:
|
|
16
|
+
//
|
|
17
|
+
// CODEX_HOME="" — what `export CODEX_HOME=$SOME_UNSET_VAR` leaves behind.
|
|
18
|
+
// `??` only falls back on null and undefined, so the two modules spelled
|
|
19
|
+
// that way kept the empty string and then joined onto it: join("",
|
|
20
|
+
// "auth.json") is "auth.json", a CWD-RELATIVE path. codex-auth.mjs is the
|
|
21
|
+
// module that writes the rotated OpenAI refresh token back to disk, and
|
|
22
|
+
// OpenAI rotates that token single-use — so writing it to whatever
|
|
23
|
+
// directory the deck happened to be started from does not merely lose a
|
|
24
|
+
// read, it burns the credential and costs the user a `codex login`.
|
|
25
|
+
//
|
|
26
|
+
// CODEX_HOME=./relative — kept verbatim by three of the five, so the tree
|
|
27
|
+
// they read was resolved against the CWD at the moment of each readdir()
|
|
28
|
+
// rather than once at startup. Two modules therefore read a different
|
|
29
|
+
// directory than the other three whenever the deck was started from
|
|
30
|
+
// anywhere but the parent of that relative path.
|
|
31
|
+
//
|
|
32
|
+
// This module is the single answer to all of it, and every reader now imports
|
|
33
|
+
// it. The rule is the `resolve()` + truthiness form index.mjs and installer.mjs
|
|
34
|
+
// already used, because it is the one the installer writes against and the one
|
|
35
|
+
// that treats an empty value as "not set" — see codexHome() for why that is the
|
|
36
|
+
// right way round rather than merely the majority.
|
|
37
|
+
import { readdir } from "node:fs/promises";
|
|
38
|
+
import { homedir } from "node:os";
|
|
39
|
+
import { join, posix as posixPath, win32 as winPath } from "node:path";
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Absolute path of the Codex home directory: $CODEX_HOME or ~/.codex.
|
|
43
|
+
*
|
|
44
|
+
* WHY AN EMPTY VALUE FALLS BACK. An empty environment variable is not a path.
|
|
45
|
+
* It is what a shell leaves behind when the variable it was assigned from does
|
|
46
|
+
* not exist, and treating it as "the current directory" turns a typo in a
|
|
47
|
+
* profile into a deck that reads and WRITES Codex state in whatever directory
|
|
48
|
+
* it was launched from. Falling back to ~/.codex is the only reading that can
|
|
49
|
+
* be right by accident; there is no user who means "put my Codex credentials in
|
|
50
|
+
* $PWD" and spells it by leaving the variable empty.
|
|
51
|
+
*
|
|
52
|
+
* WHY IT IS TRIMMED. Same argument one step further: `CODEX_HOME=" "` is the
|
|
53
|
+
* same accident with a stray space in the profile line, and claudeConfigDir()
|
|
54
|
+
* has trimmed for exactly this reason since it was written. Trimming only ever
|
|
55
|
+
* removes leading and trailing whitespace, so a real path that CONTAINS spaces
|
|
56
|
+
* — `/Users/me/Library/Application Support/codex` — is untouched, which is the
|
|
57
|
+
* case worth protecting on macOS and Windows both.
|
|
58
|
+
*
|
|
59
|
+
* WHY resolve() AND NOT realpath(). resolve() makes a relative value absolute
|
|
60
|
+
* once, here, instead of leaving every later readdir() to resolve it against
|
|
61
|
+
* whatever the CWD is by then. It deliberately does NOT follow symlinks:
|
|
62
|
+
* ~/.codex is often a link into a dotfiles repo or an encrypted volume, and
|
|
63
|
+
* canonicalising it would (a) require the directory to already exist, which it
|
|
64
|
+
* does not before the first `codex login`, and (b) make the deck rename over
|
|
65
|
+
* the link's target instead of through the link — the very thing codex-auth.mjs
|
|
66
|
+
* resolves symlinks at WRITE time to avoid. A symlinked CODEX_HOME therefore
|
|
67
|
+
* stays spelled the way the user spelled it, on purpose.
|
|
68
|
+
*
|
|
69
|
+
* The environment, home directory and platform are parameters purely so this
|
|
70
|
+
* rule can be checked for a machine the author is not sitting at: the Windows
|
|
71
|
+
* answer — drive letters, backslashes, a trailing `\` — is only ever verifiable
|
|
72
|
+
* from a POSIX box if the path flavour follows the argument rather than the
|
|
73
|
+
* host, the same trick exec.mjs and claudeCliCandidates() already use. Every
|
|
74
|
+
* caller in the deck passes nothing and gets the real machine's answer.
|
|
75
|
+
*/
|
|
76
|
+
export function codexHome(env = process.env, home = homedir(), platform = process.platform) {
|
|
77
|
+
// The path flavour follows the PLATFORM ARGUMENT, not the host, so that a
|
|
78
|
+
// Windows value is normalised by Windows rules even when the check runs on a
|
|
79
|
+
// Mac. On a real machine `platform` is `process.platform`, which makes this
|
|
80
|
+
// the same `join`/`resolve` the module would have imported anyway.
|
|
81
|
+
const path = platform === "win32" ? winPath : posixPath;
|
|
82
|
+
const override = env.CODEX_HOME?.trim();
|
|
83
|
+
return override ? path.resolve(override) : path.join(home, ".codex");
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Absolute path of the rollout tree: $CODEX_HOME/sessions.
|
|
88
|
+
*
|
|
89
|
+
* Codex writes one append-only JSONL file per session under
|
|
90
|
+
* sessions/YYYY/MM/DD/, and this directory is the root of every walk below. It
|
|
91
|
+
* lives here rather than being spelled `join(CODEX_HOME, "sessions")` at each
|
|
92
|
+
* of the three walkers, because that spelling was already duplicated verbatim
|
|
93
|
+
* in index.mjs and codex-usage.mjs and had no owner to drift away from.
|
|
94
|
+
*/
|
|
95
|
+
export function codexSessionsDir(env = process.env, home = homedir(), platform = process.platform) {
|
|
96
|
+
const path = platform === "win32" ? winPath : posixPath;
|
|
97
|
+
return path.join(codexHome(env, home, platform), "sessions");
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The answers for THIS process, resolved once at load.
|
|
102
|
+
*
|
|
103
|
+
* Every consumer imports these constants rather than calling the functions,
|
|
104
|
+
* which is what makes it impossible for two modules to disagree: there is now a
|
|
105
|
+
* single evaluation in the whole process instead of one per importing module.
|
|
106
|
+
* The functions above stay exported because the constants cannot be re-derived
|
|
107
|
+
* for a hypothetical environment, and the rule they encode is the thing worth
|
|
108
|
+
* pinning against every shape a CODEX_HOME can take.
|
|
109
|
+
*/
|
|
110
|
+
export const CODEX_HOME = codexHome();
|
|
111
|
+
export const CODEX_SESSIONS_DIR = codexSessionsDir();
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Returned from a walkRolloutDays visitor to end the walk immediately.
|
|
115
|
+
*
|
|
116
|
+
* A symbol rather than `true` because two of the three callers build their
|
|
117
|
+
* result by pushing into an array inside the visitor, and `Array.prototype.push`
|
|
118
|
+
* returns a number — a truthy-return protocol would have made "I collected two
|
|
119
|
+
* files" indistinguishable from "stop now".
|
|
120
|
+
*/
|
|
121
|
+
export const STOP = Symbol("stop-rollout-walk");
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Walk $CODEX_HOME/sessions/YYYY/MM/DD newest-first, handing each day directory
|
|
125
|
+
* and the names inside it to `onDay(dir, files)`.
|
|
126
|
+
*
|
|
127
|
+
* Three callers walked this tree with the same four nested readdir-and-continue
|
|
128
|
+
* blocks and differed only in the last few lines: find the file carrying a given
|
|
129
|
+
* session id (index.mjs), collect everything in the newest two day directories
|
|
130
|
+
* (index.mjs again, for the watcher), and collect everything whose filename
|
|
131
|
+
* timestamp falls inside a rolling window (codex-usage.mjs). The walk is the
|
|
132
|
+
* part that has to agree — a deck that tails one set of files and reports usage
|
|
133
|
+
* from another is reporting on a session it is not showing.
|
|
134
|
+
*
|
|
135
|
+
* EVERY LEVEL SWALLOWS ITS OWN ERROR, which is deliberate rather than lazy. The
|
|
136
|
+
* tree is written by another process while this one reads it: a day directory
|
|
137
|
+
* can be created between the listing of its month and the listing of itself, a
|
|
138
|
+
* year directory can be a broken symlink on a restored backup, and none of that
|
|
139
|
+
* is a reason to stop reading the other 364 days. A missing sessions/ directory
|
|
140
|
+
* is not an error at all — it is simply a machine where Codex has not run yet.
|
|
141
|
+
*
|
|
142
|
+
* `onYear` exists for the one caller that can rule out a whole year without
|
|
143
|
+
* opening it. Years arrive newest-first, so returning STOP from it ends the
|
|
144
|
+
* walk rather than skipping a year, which is what the caller wants: once the
|
|
145
|
+
* years are older than the window, so is everything after them.
|
|
146
|
+
*/
|
|
147
|
+
export async function walkRolloutDays(onDay, { sessionsDir = CODEX_SESSIONS_DIR, onYear = null } = {}) {
|
|
148
|
+
let years;
|
|
149
|
+
// Only four-digit names are Codex's own. The filter also keeps a stray
|
|
150
|
+
// `.DS_Store` or a `latest` symlink from costing a readdir that would fail.
|
|
151
|
+
try { years = (await readdir(sessionsDir)).filter(d => /^\d{4}$/.test(d)).sort().reverse(); }
|
|
152
|
+
catch { return; }
|
|
153
|
+
for (const year of years) {
|
|
154
|
+
if (onYear && (await onYear(year)) === STOP) return;
|
|
155
|
+
let months;
|
|
156
|
+
try { months = (await readdir(join(sessionsDir, year))).sort().reverse(); }
|
|
157
|
+
catch { continue; }
|
|
158
|
+
for (const month of months) {
|
|
159
|
+
let days;
|
|
160
|
+
try { days = (await readdir(join(sessionsDir, year, month))).sort().reverse(); }
|
|
161
|
+
catch { continue; }
|
|
162
|
+
for (const day of days) {
|
|
163
|
+
const dir = join(sessionsDir, year, month, day);
|
|
164
|
+
let files;
|
|
165
|
+
try { files = await readdir(dir); }
|
|
166
|
+
catch { continue; }
|
|
167
|
+
if ((await onDay(dir, files)) === STOP) return;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
@@ -9,11 +9,14 @@
|
|
|
9
9
|
// the UI when something was dropped instead of silently showing less.
|
|
10
10
|
import { readFile } from "node:fs/promises";
|
|
11
11
|
import { join } from "node:path";
|
|
12
|
-
import {
|
|
12
|
+
import { CODEX_HOME } from "./codex-dir.mjs";
|
|
13
13
|
import { getCodexAuth, forceCodexRefresh, isCredentialHost } from "./codex-auth.mjs";
|
|
14
14
|
import { PRODUCT } from "./brand.mjs";
|
|
15
15
|
|
|
16
|
-
|
|
16
|
+
// Resolved by codex-dir.mjs rather than here. This file used to spell it
|
|
17
|
+
// `process.env.CODEX_HOME ?? join(homedir(), ".codex")`, which keeps an empty
|
|
18
|
+
// CODEX_HOME instead of falling back — and then read a CWD-relative
|
|
19
|
+
// "config.toml" for the base URL every credential below is sent to (#375).
|
|
17
20
|
const CONFIG_PATH = join(CODEX_HOME, "config.toml");
|
|
18
21
|
const DEFAULT_BASE = "https://chatgpt.com/backend-api";
|
|
19
22
|
|
|
@@ -1,17 +1,12 @@
|
|
|
1
1
|
// Aggregates Codex token usage from ~/.codex/sessions rollout JSONL files.
|
|
2
2
|
// Unlike Claude, Codex has no CLI quota command — we derive usage from the
|
|
3
3
|
// actual session logs for 5h and 7d rolling windows.
|
|
4
|
-
import {
|
|
4
|
+
import { open, stat } from "node:fs/promises";
|
|
5
5
|
import { join } from "node:path";
|
|
6
|
-
import { homedir } from "node:os";
|
|
7
6
|
import { StringDecoder } from "node:string_decoder";
|
|
7
|
+
import { STOP, walkRolloutDays } from "./codex-dir.mjs";
|
|
8
8
|
import { PRODUCT } from "./brand.mjs";
|
|
9
9
|
|
|
10
|
-
const CODEX_HOME = process.env.CODEX_HOME
|
|
11
|
-
? process.env.CODEX_HOME
|
|
12
|
-
: join(homedir(), ".codex");
|
|
13
|
-
const CODEX_SESSIONS_DIR = join(CODEX_HOME, "sessions");
|
|
14
|
-
|
|
15
10
|
// Cache results for 60s (lighter than Claude quota — reads more files)
|
|
16
11
|
let _cache = null;
|
|
17
12
|
let _cacheAt = 0;
|
|
@@ -168,35 +163,33 @@ function parseRolloutTime(filename) {
|
|
|
168
163
|
}
|
|
169
164
|
|
|
170
165
|
// List rollout files whose start times fall within the given window.
|
|
166
|
+
//
|
|
167
|
+
// The walk over $CODEX_HOME/sessions is shared with the two entry points in
|
|
168
|
+
// index.mjs (codex-dir.mjs) rather than repeated here, so the files this counts
|
|
169
|
+
// usage from are exactly the files the watcher tails. They used to be two
|
|
170
|
+
// verbatim copies of the same four nested readdirs over two verbatim copies of
|
|
171
|
+
// the same CODEX_SESSIONS_DIR — which is how one of them came to resolve a
|
|
172
|
+
// relative CODEX_HOME differently from the other (#375).
|
|
171
173
|
async function listRolloutFiles(sinceMs) {
|
|
172
174
|
const out = [];
|
|
173
|
-
let years;
|
|
174
|
-
try { years = (await readdir(CODEX_SESSIONS_DIR)).filter(d => /^\d{4}$/.test(d)).sort().reverse(); }
|
|
175
|
-
catch { return out; }
|
|
176
|
-
|
|
177
175
|
const nowMs = Date.now();
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
const
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
for (const f of files) {
|
|
191
|
-
if (!f.endsWith(".jsonl")) continue;
|
|
192
|
-
const t = parseRolloutTime(f);
|
|
193
|
-
if (t != null && nowMs - t <= sinceMs) {
|
|
194
|
-
out.push({ path: join(dir, f), startMs: t });
|
|
195
|
-
}
|
|
176
|
+
// Years arrive newest-first, so the first one that cannot hold a file in the
|
|
177
|
+
// window ends the walk: everything after it is older still. The extra day of
|
|
178
|
+
// slack covers a session that started just before the window and a filename
|
|
179
|
+
// timestamp that is UTC while the year directory is local time.
|
|
180
|
+
const oldestYear = new Date(nowMs - sinceMs - 86400000).getFullYear();
|
|
181
|
+
await walkRolloutDays(
|
|
182
|
+
(dir, files) => {
|
|
183
|
+
for (const f of files) {
|
|
184
|
+
if (!f.endsWith(".jsonl")) continue;
|
|
185
|
+
const t = parseRolloutTime(f);
|
|
186
|
+
if (t != null && nowMs - t <= sinceMs) {
|
|
187
|
+
out.push({ path: join(dir, f), startMs: t });
|
|
196
188
|
}
|
|
197
189
|
}
|
|
198
|
-
}
|
|
199
|
-
|
|
190
|
+
},
|
|
191
|
+
{ onYear: y => (parseInt(y, 10) < oldestYear ? STOP : undefined) },
|
|
192
|
+
);
|
|
200
193
|
return out;
|
|
201
194
|
}
|
|
202
195
|
|
package/src/server/index.mjs
CHANGED
|
@@ -10,6 +10,7 @@ import { dirname } from "node:path";
|
|
|
10
10
|
import { createInterface } from "node:readline";
|
|
11
11
|
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
|
12
12
|
import { claudeConfigDir } from "./claude-dir.mjs";
|
|
13
|
+
import { CODEX_SESSIONS_DIR, STOP, walkRolloutDays } from "./codex-dir.mjs";
|
|
13
14
|
import { PRODUCT } from "./brand.mjs";
|
|
14
15
|
import { invokedName, renameNotice } from "./invoked-as.mjs";
|
|
15
16
|
import { codexCwdInWorkspace, writesCodexLog } from "./log-writer.mjs";
|
|
@@ -634,17 +635,17 @@ function maybeResolveContext(payload) {
|
|
|
634
635
|
// output_tokens, reasoning_output_tokens,
|
|
635
636
|
// total_tokens}}}}
|
|
636
637
|
// We resolve the rollout path lazily (cache sid→path), then read the tail
|
|
637
|
-
// for usage + model. CODEX_HOME overrides ~/.codex.
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
//
|
|
642
|
-
// to build its own `join(homedir(), ".codex", "sessions")` for the purpose
|
|
643
|
-
// which ignored CODEX_HOME and so named a directory that does not exist on any
|
|
638
|
+
// for usage + model. CODEX_HOME overrides ~/.codex, and codex-dir.mjs owns that
|
|
639
|
+
// rule for every module on the Codex side — this one used to spell it inline,
|
|
640
|
+
// which is how five modules ended up with three spellings of it (#375).
|
|
641
|
+
//
|
|
642
|
+
// Re-exported because bin/deck.js prints this path in the boot banner, and it
|
|
643
|
+
// used to build its own `join(homedir(), ".codex", "sessions")` for the purpose
|
|
644
|
+
// — which ignored CODEX_HOME and so named a directory that does not exist on any
|
|
644
645
|
// machine that sets it. Handing out the binding the watcher itself reads, rather
|
|
645
646
|
// than a second computation of the same rule, is what makes the printed path and
|
|
646
647
|
// the tailed path unable to disagree.
|
|
647
|
-
export
|
|
648
|
+
export { CODEX_SESSIONS_DIR };
|
|
648
649
|
const codexRolloutPathBySid = new Map();
|
|
649
650
|
const lastCodexUsageReadAt = new Map();
|
|
650
651
|
const pendingCodexUsageReads = new Set();
|
|
@@ -653,34 +654,19 @@ const CODEX_READ_THROTTLE_MS = 2500;
|
|
|
653
654
|
async function findCodexRolloutPath(sid) {
|
|
654
655
|
const cached = codexRolloutPathBySid.get(sid);
|
|
655
656
|
if (cached) return cached;
|
|
656
|
-
// Walk year → month → day → files. Codex includes the sid in the
|
|
657
|
-
// (rollout-...-<sid>.jsonl) so a directory-scoped match is enough.
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
catch { continue; }
|
|
670
|
-
for (const d of days) {
|
|
671
|
-
const dayDir = join(CODEX_SESSIONS_DIR, y, m, d);
|
|
672
|
-
let files;
|
|
673
|
-
try { files = await readdir(dayDir); } catch { continue; }
|
|
674
|
-
const hit = files.find(f => f.includes(sid) && f.endsWith(".jsonl"));
|
|
675
|
-
if (hit) {
|
|
676
|
-
const full = join(dayDir, hit);
|
|
677
|
-
codexRolloutPathBySid.set(sid, full);
|
|
678
|
-
return full;
|
|
679
|
-
}
|
|
680
|
-
}
|
|
681
|
-
}
|
|
682
|
-
}
|
|
683
|
-
return null;
|
|
657
|
+
// Walk year → month → day → files, newest first. Codex includes the sid in the
|
|
658
|
+
// filename (rollout-...-<sid>.jsonl) so a directory-scoped match is enough.
|
|
659
|
+
// The walk itself lives in codex-dir.mjs, shared with the watcher's listing
|
|
660
|
+
// below and with codex-usage.mjs, so all three read one tree the same way.
|
|
661
|
+
let found = null;
|
|
662
|
+
await walkRolloutDays((dayDir, files) => {
|
|
663
|
+
const hit = files.find(f => f.includes(sid) && f.endsWith(".jsonl"));
|
|
664
|
+
if (!hit) return;
|
|
665
|
+
found = join(dayDir, hit);
|
|
666
|
+
return STOP;
|
|
667
|
+
});
|
|
668
|
+
if (found) codexRolloutPathBySid.set(sid, found);
|
|
669
|
+
return found;
|
|
684
670
|
}
|
|
685
671
|
|
|
686
672
|
/** Tail-read a Codex rollout JSONL. Returns the last token_count info block
|
|
@@ -788,7 +774,9 @@ function maybeResolveCodex(payload) {
|
|
|
788
774
|
// event_msg/user_message → UserPromptSubmit (Codex ≤ 0.144)
|
|
789
775
|
// event_msg/item_completed/UserMessage → UserPromptSubmit (Codex ≥ 0.147)
|
|
790
776
|
// response_item/function_call → PreToolUse
|
|
791
|
-
// response_item/function_call_output → PostToolUse
|
|
777
|
+
// response_item/function_call_output → PostToolUse / PostToolUseFailure,
|
|
778
|
+
// decided by the outcome line Codex
|
|
779
|
+
// prepends to the output (codexCallFailed)
|
|
792
780
|
// event_msg/token_count → UsageObserved
|
|
793
781
|
// event_msg/task_started (+window) → ModelObserved (context window)
|
|
794
782
|
// event_msg/task_complete → Stop (the turn finished)
|
|
@@ -844,25 +832,13 @@ async function readLiveDecks() {
|
|
|
844
832
|
// of history every tick.
|
|
845
833
|
async function listRecentCodexRollouts() {
|
|
846
834
|
const out = [];
|
|
847
|
-
let years;
|
|
848
|
-
try { years = (await readdir(CODEX_SESSIONS_DIR)).filter(d => /^\d{4}$/.test(d)).sort().reverse(); }
|
|
849
|
-
catch { return out; }
|
|
850
835
|
let dayDirs = 0;
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
for (const d of days) {
|
|
858
|
-
const dir = join(CODEX_SESSIONS_DIR, y, m, d);
|
|
859
|
-
let files;
|
|
860
|
-
try { files = await readdir(dir); } catch { continue; }
|
|
861
|
-
for (const f of files) if (f.endsWith(".jsonl")) out.push(join(dir, f));
|
|
862
|
-
if (++dayDirs >= 2) return out;
|
|
863
|
-
}
|
|
864
|
-
}
|
|
865
|
-
}
|
|
836
|
+
await walkRolloutDays((dir, files) => {
|
|
837
|
+
for (const f of files) if (f.endsWith(".jsonl")) out.push(join(dir, f));
|
|
838
|
+
// Two day-directories deep is the whole point of this listing: it runs every
|
|
839
|
+
// tick, and anything older than that is a session no process will append to.
|
|
840
|
+
if (++dayDirs >= 2) return STOP;
|
|
841
|
+
});
|
|
866
842
|
return out;
|
|
867
843
|
}
|
|
868
844
|
|
|
@@ -989,14 +965,92 @@ export function codexObjToPayload(obj, sid, cwd) {
|
|
|
989
965
|
if (pl.type === "custom_tool_call") {
|
|
990
966
|
return { ...base, hook_event_name: "PreToolUse", tool_name: pl.name ?? "tool", tool_input: { patch: pl.input }, tool_use_id: pl.call_id, model };
|
|
991
967
|
}
|
|
968
|
+
// #397: the outcome, not just the fact that an outcome arrived. This used
|
|
969
|
+
// to hardcode "PostToolUse" for both output types, and the reducer derives
|
|
970
|
+
// `ok` from the event NAME (`tc.ok = name === "PostToolUse"`) — so `ok` was
|
|
971
|
+
// structurally incapable of being false on the Codex path and a command
|
|
972
|
+
// that exited non-zero drew exactly like one that succeeded. Every surface
|
|
973
|
+
// that reads the flag inherited the lie: the burst dot, the tool row, the
|
|
974
|
+
// ToolModal styling, the detail-panel error count, and the session
|
|
975
|
+
// summary's "Errors" stat, which was therefore pinned at 0 for the life of
|
|
976
|
+
// a Codex session. `PostToolUseFailure` is the name the reducer already
|
|
977
|
+
// understands (it sets `ok = false` and writes an `errorPreview` from the
|
|
978
|
+
// response); nothing but this mapper was missing.
|
|
992
979
|
if (pl.type === "function_call_output" || pl.type === "custom_tool_call_output") {
|
|
993
980
|
const tool_response = pl.output != null ? parseCodexOutput(pl.output) : undefined;
|
|
994
|
-
|
|
981
|
+
const name = codexCallFailed(pl.output) ? "PostToolUseFailure" : "PostToolUse";
|
|
982
|
+
return { ...base, hook_event_name: name, tool_use_id: pl.call_id, tool_response, model };
|
|
995
983
|
}
|
|
996
984
|
}
|
|
997
985
|
return null;
|
|
998
986
|
}
|
|
999
987
|
|
|
988
|
+
/**
|
|
989
|
+
* The text parts of a Codex tool result, in the order Codex wrote them.
|
|
990
|
+
*
|
|
991
|
+
* Codex writes the result in two different containers and the deck sees both,
|
|
992
|
+
* so this is where the difference stops. Across the rollouts sampled here:
|
|
993
|
+
* `custom_tool_call_output.output` is an ARRAY of `{ type: "input_text", text }`
|
|
994
|
+
* parts (85/85, on 0.144 and 0.147 alike), and `function_call_output.output` is
|
|
995
|
+
* a bare string (32/32). The `{ output, metadata }` envelope `parseCodexOutput`
|
|
996
|
+
* unwraps was written by neither, but it is cheap to keep tolerating and the
|
|
997
|
+
* unwrapping already lives there, so the string case is routed through it
|
|
998
|
+
* rather than duplicating the guess.
|
|
999
|
+
*/
|
|
1000
|
+
function codexOutputParts(output) {
|
|
1001
|
+
if (output == null) return [];
|
|
1002
|
+
if (Array.isArray(output)) {
|
|
1003
|
+
return output.map(p => (p && typeof p.text === "string" ? p.text : ""));
|
|
1004
|
+
}
|
|
1005
|
+
const unwrapped = parseCodexOutput(output);
|
|
1006
|
+
return typeof unwrapped === "string" ? [unwrapped] : [];
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
/**
|
|
1010
|
+
* Did this Codex tool call actually fail?
|
|
1011
|
+
*
|
|
1012
|
+
* Codex prepends its own wrapper line to the tool's output and that line — not
|
|
1013
|
+
* any structured field — is where the outcome lives. Verified against every
|
|
1014
|
+
* tool result in this machine's CODEX_HOME:
|
|
1015
|
+
*
|
|
1016
|
+
* 0.144.5 exec "Script completed" 75 "Script failed" 2
|
|
1017
|
+
* 0.147.0 exec "Script completed" 6 (no failure observed)
|
|
1018
|
+
* 0.144.5 apply_patch "Exit code: 0" 2
|
|
1019
|
+
* 0.144.5 exec_command / run — bare string, no wrapper line at all 32
|
|
1020
|
+
*
|
|
1021
|
+
* The two CLI versions spell it IDENTICALLY, which is why one rule covers both
|
|
1022
|
+
* and why this needs no version sniffing: 0.147 renamed the prompt event (see
|
|
1023
|
+
* the `item_completed` branch above) but left the exec wrapper alone.
|
|
1024
|
+
*
|
|
1025
|
+
* Only the FIRST part's FIRST line is read, and that precision is load-bearing
|
|
1026
|
+
* rather than tidiness. The wrapper line is at part index 0 in 85 of 85 results
|
|
1027
|
+
* that have one; the later parts are the command's own stdout, and the command
|
|
1028
|
+
* prints whatever it likes there. On this machine two exec results contain a
|
|
1029
|
+
* line reading "Script error:" in part 1 — output from a script that ran fine
|
|
1030
|
+
* under a wrapper that says "Script completed" — so a rule that scanned every
|
|
1031
|
+
* part would paint two successful calls red. `\r` is stripped because the same
|
|
1032
|
+
* wrapper is written by Codex on Windows.
|
|
1033
|
+
*
|
|
1034
|
+
* Silence means success, deliberately. A result with no wrapper line — every
|
|
1035
|
+
* `function_call_output` on 0.144, and whatever container a future Codex
|
|
1036
|
+
* invents — keeps today's behaviour of mapping to `PostToolUse`. Reporting an
|
|
1037
|
+
* unknown outcome as a failure would trade one wrong colour for another, and
|
|
1038
|
+
* this direction is the recoverable one: a missed failure is a call that draws
|
|
1039
|
+
* as it always has, while a false failure puts a red dot and an "Errors" count
|
|
1040
|
+
* on a session that did nothing wrong.
|
|
1041
|
+
*/
|
|
1042
|
+
function codexCallFailed(output) {
|
|
1043
|
+
const first = codexOutputParts(output)[0];
|
|
1044
|
+
if (typeof first !== "string") return false;
|
|
1045
|
+
const head = first.split("\n")[0].replace(/\r$/, "");
|
|
1046
|
+
if (/^Script failed\b/.test(head)) return true;
|
|
1047
|
+
// apply_patch reports itself with an exit code instead of a word. Anything
|
|
1048
|
+
// non-zero is a patch that did not apply.
|
|
1049
|
+
const exit = /^Exit code:\s*(\d+)/.exec(head);
|
|
1050
|
+
if (exit) return Number(exit[1]) !== 0;
|
|
1051
|
+
return false;
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1000
1054
|
function parseCodexOutput(raw) {
|
|
1001
1055
|
if (typeof raw !== "string") return raw;
|
|
1002
1056
|
try {
|
package/src/server/installer.mjs
CHANGED
|
@@ -7,24 +7,25 @@
|
|
|
7
7
|
// with __agent-dag and de-duped.
|
|
8
8
|
import { readFile, mkdir, unlink, rename, open, stat, chmod } from "node:fs/promises";
|
|
9
9
|
import { existsSync } from "node:fs";
|
|
10
|
-
import { homedir } from "node:os";
|
|
11
10
|
import { join, resolve, dirname } from "node:path";
|
|
12
11
|
import { setTimeout as delay } from "node:timers/promises";
|
|
13
12
|
import { fileURLToPath } from "node:url";
|
|
14
13
|
import { claudeConfigDir } from "./claude-dir.mjs";
|
|
14
|
+
import { CODEX_HOME } from "./codex-dir.mjs";
|
|
15
15
|
import { shellQuoteArg } from "./exec.mjs";
|
|
16
16
|
import { PRODUCT } from "./brand.mjs";
|
|
17
17
|
|
|
18
18
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
19
19
|
const PKG_ROOT = resolve(__dirname, "..", "..");
|
|
20
20
|
|
|
21
|
-
const HOME = homedir();
|
|
22
21
|
// Honours CLAUDE_CONFIG_DIR, exactly as CODEX_DIR honours CODEX_HOME below.
|
|
23
22
|
// Without it the hooks land in a settings.json Claude Code never opens.
|
|
24
23
|
const CLAUDE_DIR = claudeConfigDir();
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
24
|
+
// Both directories now come from the module that owns the rule rather than from
|
|
25
|
+
// a copy of it here — claude-dir.mjs and codex-dir.mjs. The local name stays
|
|
26
|
+
// because CODEX_DIR is what the rest of this file and its tests call it, and it
|
|
27
|
+
// says what the value is FOR here: the directory hooks.json is taken out of.
|
|
28
|
+
const CODEX_DIR = CODEX_HOME;
|
|
28
29
|
|
|
29
30
|
// Single shared discovery dir — both providers' hook scripts post here so one
|
|
30
31
|
// running agent-dag server can match either ecosystem's events. It follows the
|