ccdeck 3.29.10 → 3.30.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 +1 -1
- package/bin/agent-dag.js +67 -118
- package/bin/cli/help.js +67 -0
- package/bin/cli/login-item.js +182 -0
- package/bin/cli/one-shot.js +254 -0
- package/bin/cli/package.js +39 -0
- package/bin/cli/pulse.js +98 -0
- package/bin/cli/restart.js +179 -0
- package/bin/cli/screen.js +166 -0
- package/bin/cli/second-start.js +120 -0
- package/bin/cli/startup.js +459 -0
- package/bin/cli/uninstall.js +169 -0
- package/bin/deck.js +112 -1559
- package/dist/web/assets/BrowserWatchModal-DP3YMtEi.js +1 -0
- package/dist/web/assets/UsageHistoryModal-CcdPVXAw.js +1 -0
- package/dist/web/assets/index-3vS6A0vt.css +1 -0
- package/dist/web/assets/index-BVTWHDta.js +298 -0
- package/dist/web/index.html +2 -2
- package/hook/hook.js +15 -14
- package/package.json +1 -1
- package/release-notes.json +28 -0
- package/src/server/account-projects.mjs +25 -11
- package/src/server/account-routes.mjs +254 -0
- package/src/server/activity.mjs +3 -3
- package/src/server/append-budget.mjs +185 -0
- package/src/server/append-failures.mjs +147 -0
- package/src/server/args.mjs +37 -5
- package/src/server/atomic-write.mjs +300 -0
- package/src/server/block-notify.mjs +0 -5
- package/src/server/boot-lock.mjs +1 -1
- package/src/server/brand.mjs +7 -6
- package/src/server/browser-react.mjs +10 -2
- package/src/server/browser-watch-decks.mjs +146 -0
- package/src/server/browser-watch-feed.mjs +69 -0
- package/src/server/browser-watch-log.mjs +173 -0
- package/src/server/browser-watch-mtime.mjs +18 -0
- package/src/server/browser-watch-relay.mjs +112 -0
- package/src/server/browser-watch-routes.mjs +172 -0
- package/src/server/browser-watch-seen.mjs +127 -0
- package/src/server/browser-watch-store.mjs +14 -143
- package/src/server/browser-watch.mjs +278 -626
- package/src/server/canonical-path.mjs +150 -0
- package/src/server/ccusage-failure.mjs +64 -0
- package/src/server/ccusage-install.mjs +459 -0
- package/src/server/ccusage-runner.mjs +271 -0
- package/src/server/ccusage.mjs +21 -756
- package/src/server/claude-accounts.mjs +220 -464
- package/src/server/claude-collector.mjs +189 -0
- package/src/server/claude-dir.mjs +2 -2
- package/src/server/claude-identity.mjs +5 -4
- package/src/server/claude-reset-credits.mjs +130 -0
- package/src/server/claude-verdicts.mjs +174 -0
- package/src/server/clear-route.mjs +191 -0
- package/src/server/codex-auth.mjs +4 -4
- package/src/server/codex-dir.mjs +52 -8
- package/src/server/codex-enrichment.mjs +310 -0
- package/src/server/codex-quota.mjs +5 -3
- package/src/server/codex-translate.mjs +407 -0
- package/src/server/codex-usage.mjs +8 -5
- package/src/server/codex-watch.mjs +496 -0
- package/src/server/cswap-auto-loop.mjs +342 -0
- package/src/server/cswap-auto-readers.mjs +303 -0
- package/src/server/cswap-auto.mjs +51 -609
- package/src/server/cswap-install.mjs +69 -265
- package/src/server/cswap-layout.mjs +242 -0
- package/src/server/deck-home.mjs +1 -1
- package/src/server/deck-prefs.mjs +51 -184
- package/src/server/deck-scope.mjs +52 -0
- package/src/server/desktop-update-routes.mjs +75 -0
- package/src/server/detach.mjs +78 -0
- package/src/server/discovery.mjs +298 -0
- package/src/server/event-log.mjs +328 -0
- package/src/server/event-pipeline.mjs +235 -0
- package/src/server/event-ring.mjs +214 -0
- package/src/server/event-routes.mjs +352 -0
- package/src/server/event-sink.mjs +26 -0
- package/src/server/exec-children.mjs +128 -0
- package/src/server/exec-not-found.mjs +213 -0
- package/src/server/exec-output.mjs +23 -0
- package/src/server/exec-spec.mjs +341 -0
- package/src/server/exec.mjs +74 -661
- package/src/server/health-route.mjs +42 -0
- package/src/server/hook-challenge.mjs +37 -0
- package/src/server/http-io.mjs +104 -0
- package/src/server/index.mjs +172 -7620
- package/src/server/install-layout.mjs +506 -0
- package/src/server/installer.mjs +79 -608
- package/src/server/jsonl-chunks.mjs +139 -0
- package/src/server/lan-accounts.mjs +27 -0
- package/src/server/lan-beacon.mjs +457 -0
- package/src/server/lan-call.mjs +239 -0
- package/src/server/lan-copies.mjs +270 -0
- package/src/server/lan-deck.mjs +583 -0
- package/src/server/lan-dials.mjs +161 -0
- package/src/server/lan-engine.mjs +475 -1024
- package/src/server/lan-hearing.mjs +49 -0
- package/src/server/lan-inbound.mjs +100 -0
- package/src/server/lan-invite-offer.mjs +77 -0
- package/src/server/lan-invite.mjs +207 -0
- package/src/server/lan-lines.mjs +73 -0
- package/src/server/lan-manifest.mjs +74 -0
- package/src/server/lan-reach.mjs +82 -69
- package/src/server/lan-requests.mjs +249 -0
- package/src/server/lan-round-record.mjs +45 -0
- package/src/server/lan-round-timer.mjs +62 -0
- package/src/server/lan-routes.mjs +143 -0
- package/src/server/lan-serve.mjs +112 -0
- package/src/server/lan-socket.mjs +243 -925
- package/src/server/lan-sync.mjs +58 -1026
- package/src/server/lan-tailnet-poll.mjs +57 -0
- package/src/server/lan-turns.mjs +80 -0
- package/src/server/lan-wire.mjs +620 -0
- package/src/server/lifecycle.mjs +421 -0
- package/src/server/listen.mjs +110 -0
- package/src/server/live-decks.mjs +138 -0
- package/src/server/load-average.mjs +32 -0
- package/src/server/log-election.mjs +235 -0
- package/src/server/log-replay.mjs +374 -0
- package/src/server/log-writer.mjs +72 -557
- package/src/server/login-service.mjs +3 -2
- package/src/server/macmon.mjs +1 -1
- package/src/server/memory-files.mjs +163 -0
- package/src/server/memory-metrics.mjs +178 -0
- package/src/server/metrics-history.mjs +94 -0
- package/src/server/metrics-run.mjs +100 -0
- package/src/server/music-routes.mjs +134 -0
- package/src/server/network-metrics.mjs +5 -3
- package/src/server/network-sampler.mjs +256 -0
- package/src/server/npm-latest.mjs +385 -0
- package/src/server/npm-upgrade.mjs +352 -0
- package/src/server/npx.mjs +41 -7
- package/src/server/output-watch.mjs +5 -4
- package/src/server/pinned-build.mjs +108 -0
- package/src/server/prefs-path.mjs +14 -0
- package/src/server/prefs-refusal.mjs +77 -0
- package/src/server/prefs-routes.mjs +76 -0
- package/src/server/prefs-set-aside.mjs +86 -0
- package/src/server/prefs-state.mjs +51 -0
- package/src/server/process-command.mjs +104 -0
- package/src/server/process-list.mjs +547 -0
- package/src/server/pulse-line.mjs +132 -0
- package/src/server/purge-key.mjs +7 -7
- package/src/server/quota-cli.mjs +174 -0
- package/src/server/quota-oauth.mjs +190 -0
- package/src/server/quota-shape.mjs +229 -0
- package/src/server/quota-store-resets.mjs +206 -0
- package/src/server/quota.mjs +123 -509
- package/src/server/relay-guard.mjs +3 -3
- package/src/server/request-gates.mjs +441 -0
- package/src/server/restart-note.mjs +180 -0
- package/src/server/retire-sound-hook.mjs +3 -3
- package/src/server/ring-bounds.mjs +215 -0
- package/src/server/running-deck.mjs +1 -1
- package/src/server/running-version.mjs +24 -0
- package/src/server/self-update.mjs +39 -1371
- package/src/server/session-enrichment.mjs +622 -0
- package/src/server/session-read-gate.mjs +49 -0
- package/src/server/session-tracking.mjs +242 -0
- package/src/server/sse-clients.mjs +222 -0
- package/src/server/stale-discovery.mjs +30 -0
- package/src/server/static-serve.mjs +81 -0
- package/src/server/supervisor.mjs +73 -12
- package/src/server/system-metrics.mjs +169 -1935
- package/src/server/term.mjs +11 -249
- package/src/server/thermal-metrics.mjs +520 -0
- package/src/server/thermal-sampler.mjs +204 -0
- package/src/server/token-redact.mjs +123 -0
- package/src/server/transcript-gate.mjs +107 -0
- package/src/server/transcript-scan.mjs +563 -0
- package/src/server/usage-routes.mjs +93 -0
- package/src/server/uv-bootstrap.mjs +1 -1
- package/src/server/wordmark.mjs +127 -0
- package/dist/web/assets/BrowserWatchModal-CMn28FHb.js +0 -1
- package/dist/web/assets/UsageHistoryModal-C-sPhsKT.js +0 -1
- package/dist/web/assets/index-C1YFI9f4.js +0 -298
- package/dist/web/assets/index-Ck2RAla3.css +0 -1
|
@@ -0,0 +1,496 @@
|
|
|
1
|
+
// The Codex half of capture: the rollouts directory tailed for the events
|
|
2
|
+
// themselves. A session's rollout found by id, for the usage its hook payloads
|
|
3
|
+
// never carry, is codex-enrichment.mjs.
|
|
4
|
+
//
|
|
5
|
+
// These lived in src/server/index.mjs, between the session enrichment and the
|
|
6
|
+
// ring's reader. They reach the pipeline through event-sink.mjs, as the
|
|
7
|
+
// enrichment does, and take the log election, the live decks, the canonical
|
|
8
|
+
// cwd and the chunked reader from the modules that own them — nothing here
|
|
9
|
+
// reads the ring or the SSE clients. index.mjs starts the watcher. The bodies
|
|
10
|
+
// are unchanged.
|
|
11
|
+
import { stat } from "node:fs/promises";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
import { PRODUCT } from "./brand.mjs";
|
|
14
|
+
import { STOP, sidFromRolloutName, walkRolloutDays } from "./codex-dir.mjs";
|
|
15
|
+
// What one rollout line means as a hook payload, and the shape of the header
|
|
16
|
+
// record that says whose rollout it is — see codex-translate.mjs. The watcher
|
|
17
|
+
// below still decides which lines are read and where each one goes.
|
|
18
|
+
import { codexObjToPayload, codexSessionModel, sessionMeta } from "./codex-translate.mjs";
|
|
19
|
+
import { codexCwdInWorkspace, writesCodexLog } from "./log-election.mjs";
|
|
20
|
+
// The one spelling of a rollout's cwd — see canonical-path.mjs.
|
|
21
|
+
import { canonicalCwd } from "./canonical-path.mjs";
|
|
22
|
+
import { eventLogPath } from "./event-log.mjs";
|
|
23
|
+
// Every deck registered right now that proved it is the deck its record
|
|
24
|
+
// describes — what the log election counts. See live-decks.mjs.
|
|
25
|
+
import { readLiveDecks } from "./live-decks.mjs";
|
|
26
|
+
// The bounded chunk reads the Claude transcripts use — see jsonl-chunks.mjs.
|
|
27
|
+
import { readAppendedLines, readByteRange } from "./jsonl-chunks.mjs";
|
|
28
|
+
import { maybeResolveCodexMemory } from "./session-enrichment.mjs";
|
|
29
|
+
// event-pipeline.mjs's pushEvent, reached without importing it — see
|
|
30
|
+
// event-sink.mjs.
|
|
31
|
+
import { pushEvent } from "./event-sink.mjs";
|
|
32
|
+
|
|
33
|
+
// ─── Codex rollout watcher ────────────────────────────────────────────────
|
|
34
|
+
// Codex CLI hooks never fire on Windows — the elevated/unelevated sandbox
|
|
35
|
+
// refuses to spawn the hook command (exit 1, child never runs). So instead of
|
|
36
|
+
// relying on hooks, we tail the rollout JSONL files Codex writes to
|
|
37
|
+
// ~/.codex/sessions/YYYY/MM/DD/rollout-<ts>-<sid>.jsonl and reconstruct the
|
|
38
|
+
// agent-dag event stream from them. Each rollout line is one append-only JSON
|
|
39
|
+
// object {timestamp, type, payload}; we map the relevant ones to the same
|
|
40
|
+
// synthetic hook payloads the reducer already understands:
|
|
41
|
+
// session_meta → SessionStart, but ONLY for a rollout
|
|
42
|
+
// this watcher read from byte 0 (#684).
|
|
43
|
+
// See ensureCodexRoot.
|
|
44
|
+
// event_msg/user_message → UserPromptSubmit (Codex ≤ 0.144)
|
|
45
|
+
// event_msg/item_completed/UserMessage → UserPromptSubmit (Codex ≥ 0.147)
|
|
46
|
+
// response_item/function_call → PreToolUse
|
|
47
|
+
// response_item/function_call_output → PostToolUse / PostToolUseFailure,
|
|
48
|
+
// decided by the outcome line Codex
|
|
49
|
+
// prepends to the output (codexCallFailed)
|
|
50
|
+
// event_msg/token_count → UsageObserved, and riding on it the
|
|
51
|
+
// session's live context occupancy and
|
|
52
|
+
// the CLI's own window (#399)
|
|
53
|
+
// event_msg/task_started (+window) → ModelObserved (context window)
|
|
54
|
+
// event_msg/task_complete → Stop (the turn finished)
|
|
55
|
+
// event_msg/turn_aborted → Stop (the turn was interrupted)
|
|
56
|
+
// turn_context / response_item.model → model snapshot (ModelObserved on change)
|
|
57
|
+
// turn_context.approval_policy → session snapshot, spread onto every
|
|
58
|
+
// payload below (#398); no event of its own
|
|
59
|
+
// There is deliberately no SessionEnd here: Codex writes no session-close
|
|
60
|
+
// record, so the end of a SESSION is still inferred by sweepStaleSessions.
|
|
61
|
+
// Nor is there anything that maps to `Notification`, and no synthetic one is
|
|
62
|
+
// invented: Codex writes no approval record to a rollout at all, so the deck
|
|
63
|
+
// cannot see a Codex session blocked on a human and does not pretend to — see
|
|
64
|
+
// codex-approval.ts for the evidence and for what is said instead of guessing.
|
|
65
|
+
// Events are emitted with source "codex" so pushEvent skips the Claude-only
|
|
66
|
+
// transcript enrichment (which needs transcript_path / hook events) but still
|
|
67
|
+
// broadcasts them exactly like a hook event, and persists them when this deck is
|
|
68
|
+
// the one elected to log this rollout — see writesCodexLog. This path is
|
|
69
|
+
// entirely additive — the Claude hook flow is untouched.
|
|
70
|
+
// path -> { offset, sid, cwd, skip, sawBeginning, rootOpened, seenAt }
|
|
71
|
+
const codexFileState = new Map();
|
|
72
|
+
// How long a rollout's tail cursor is kept after it stops showing up in the
|
|
73
|
+
// listing. The listing covers two day-directories, so anything missing from it
|
|
74
|
+
// is at least a day old and will never be appended to again.
|
|
75
|
+
const CODEX_STATE_TTL_MS = 10 * 60 * 1000;
|
|
76
|
+
// Every rollout path this PROCESS has ever held a cursor for, which the TTL
|
|
77
|
+
// sweep deliberately does not clear — that is the whole of its job.
|
|
78
|
+
//
|
|
79
|
+
// A cursor dropped and then re-created is indistinguishable, to the code that
|
|
80
|
+
// creates it, from a rollout nobody has ever read: both are "no entry in
|
|
81
|
+
// codexFileState". The difference matters enormously, because a new file is
|
|
82
|
+
// opened at byte 0 and told it has its own beginning, so a re-discovery
|
|
83
|
+
// replayed the entire rollout as fresh events AND minted a `SessionStart` for a
|
|
84
|
+
// session this deck joined late. This set is what lets the second case answer
|
|
85
|
+
// "no, we have seen this one" and park at the end instead (#981).
|
|
86
|
+
//
|
|
87
|
+
// Capped, in insertion order, for the reason codexFileState is swept at all: a
|
|
88
|
+
// long-lived deck on a busy machine would otherwise accumulate a path per
|
|
89
|
+
// session for as long as it runs. A path is far cheaper than a cursor, so the
|
|
90
|
+
// cap is generous — but it is a cap, and it is the same LRU shape
|
|
91
|
+
// sessionTouchedAt uses.
|
|
92
|
+
const codexSeenEver = new Set();
|
|
93
|
+
const MAX_CODEX_SEEN_EVER = 512;
|
|
94
|
+
function rememberCodexPath(path) {
|
|
95
|
+
// Delete-then-add so the Set's own insertion order IS the LRU order, and
|
|
96
|
+
// eviction is one key read rather than a scan.
|
|
97
|
+
codexSeenEver.delete(path);
|
|
98
|
+
codexSeenEver.add(path);
|
|
99
|
+
while (codexSeenEver.size > MAX_CODEX_SEEN_EVER) {
|
|
100
|
+
codexSeenEver.delete(codexSeenEver.values().next().value);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
// The scan in flight, if any, as its promise. A tick that lands while one is
|
|
104
|
+
// still reading joins it rather than starting a second over the same cursors,
|
|
105
|
+
// and scanCodexNow can wait it out.
|
|
106
|
+
let codexScan = null;
|
|
107
|
+
let codexWatchTimer = null;
|
|
108
|
+
let codexWorkspace = "";
|
|
109
|
+
|
|
110
|
+
// List rollout files from the newest 2 day-directories. New sessions always
|
|
111
|
+
// land in today's dir, so this captures live activity without scanning years
|
|
112
|
+
// of history every tick.
|
|
113
|
+
async function listRecentCodexRollouts() {
|
|
114
|
+
const out = [];
|
|
115
|
+
let dayDirs = 0;
|
|
116
|
+
await walkRolloutDays((dir, files) => {
|
|
117
|
+
for (const f of files) if (f.endsWith(".jsonl")) out.push(join(dir, f));
|
|
118
|
+
// Two day-directories deep is the whole point of this listing: it runs every
|
|
119
|
+
// tick, and anything older than that is a session no process will append to.
|
|
120
|
+
if (++dayDirs >= 2) return STOP;
|
|
121
|
+
});
|
|
122
|
+
return out;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Which of the two ways a rollout can fail to say whose it is has been printed
|
|
126
|
+
* already. Once per kind for the life of the process, not once per file: the
|
|
127
|
+
* scan retries a rollout it cannot place on every tick, and a change to the
|
|
128
|
+
* format would hit every rollout at once, so per file would be the same
|
|
129
|
+
* sentence once for each of them. */
|
|
130
|
+
const codexHeaderWarned = new Set();
|
|
131
|
+
function warnUnplacedRollout(kind, path, why) {
|
|
132
|
+
if (codexHeaderWarned.has(kind)) return;
|
|
133
|
+
codexHeaderWarned.add(kind);
|
|
134
|
+
console.warn(`${PRODUCT}: cannot tell which Codex session ${path} belongs to: ${why}. If Codex has changed its rollout format, its sessions will not be drawn until the deck reads the new one.`);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Read the first complete JSON line of a rollout (the session_meta header)
|
|
138
|
+
// to learn sid + cwd before we start streaming. The header line can be large
|
|
139
|
+
// (base_instructions text runs tens of KB), so we read in growing chunks until
|
|
140
|
+
// we hit the first newline rather than guessing a fixed window.
|
|
141
|
+
async function readCodexHeader(path) {
|
|
142
|
+
try {
|
|
143
|
+
const size = (await stat(path)).size;
|
|
144
|
+
if (size === 0) return null;
|
|
145
|
+
const CHUNK = 65536;
|
|
146
|
+
let upto = Math.min(CHUNK, size);
|
|
147
|
+
let text = "";
|
|
148
|
+
for (;;) {
|
|
149
|
+
text = await readByteRange(path, 0, upto);
|
|
150
|
+
const nl = text.indexOf("\n");
|
|
151
|
+
if (nl >= 0) {
|
|
152
|
+
const obj = JSON.parse(text.slice(0, nl));
|
|
153
|
+
const meta = sessionMeta(obj);
|
|
154
|
+
if (meta) {
|
|
155
|
+
// THE ID, WITH THE FILE NAME BEHIND IT (#996). Every other field this
|
|
156
|
+
// file takes off a rollout goes through a type guard, and the event
|
|
157
|
+
// names are read in both spellings because OpenAI has renamed a record
|
|
158
|
+
// once already; this one went straight into `sid`. A renamed or
|
|
159
|
+
// dropped `payload.id` made every header answer undefined, and the
|
|
160
|
+
// scan `continue`s on that — so every rollout was retried on every
|
|
161
|
+
// tick, its first 64KB re-read each time, not one Codex session was
|
|
162
|
+
// drawn, and nothing said why. The file name carries the same id.
|
|
163
|
+
const id = meta.id;
|
|
164
|
+
const sid = typeof id === "string" && id !== "" ? id : sidFromRolloutName(path);
|
|
165
|
+
if (!sid) warnUnplacedRollout("no-id", path, "its session_meta has no id, and its file name carries none");
|
|
166
|
+
// Canonicalised here and nowhere else: everything downstream — the
|
|
167
|
+
// workspace test below, the log election, the cwd on every event this
|
|
168
|
+
// rollout produces — reads state.cwd, and this is the one place it is
|
|
169
|
+
// read off disk. See canonicalCwd.
|
|
170
|
+
return { sid, cwd: await canonicalCwd(meta.cwd) };
|
|
171
|
+
}
|
|
172
|
+
// The other rename the scan would otherwise retry in silence: a first
|
|
173
|
+
// line that is whole but is not a session_meta will never become one.
|
|
174
|
+
// Said, not guessed around — which record a renamed header would be is
|
|
175
|
+
// not something to infer from one line. Only for a file named like a
|
|
176
|
+
// rollout, so some other `.jsonl` left in the tree is not reported as a
|
|
177
|
+
// format change.
|
|
178
|
+
if (obj && typeof obj === "object" && sidFromRolloutName(path)) {
|
|
179
|
+
warnUnplacedRollout("no-header", path, `its first line is a ${JSON.stringify(String(obj.type ?? "record with no type"))}, not a session_meta`);
|
|
180
|
+
}
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
if (upto >= size) return null; // no newline in the whole file yet
|
|
184
|
+
upto = Math.min(upto + CHUNK, size); // grow and retry
|
|
185
|
+
if (upto > 4 * 1024 * 1024) return null; // 4MB sanity cap on a single line
|
|
186
|
+
}
|
|
187
|
+
} catch {}
|
|
188
|
+
return null;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// `persist` is false when another deck tailing this same rollout was elected to
|
|
192
|
+
// write it to the log they share. The event is still buffered and broadcast —
|
|
193
|
+
// every deck watching the session draws it — it is only the second copy on disk
|
|
194
|
+
// that is dropped. See writesCodexLog in log-election.mjs.
|
|
195
|
+
function emitCodexEvent(payload, persist) {
|
|
196
|
+
pushEvent(payload, "codex", { persist });
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Open this rollout's session on the canvas, once, lazily — only when it
|
|
201
|
+
* actually produces an event. Emitting eagerly for every file on disk at
|
|
202
|
+
* startup would fill the canvas with empty roots for sessions nobody will ever
|
|
203
|
+
* append to again.
|
|
204
|
+
*
|
|
205
|
+
* WHETHER A `SessionStart` IS EMITTED AT ALL IS THE WHOLE OF #684.
|
|
206
|
+
*
|
|
207
|
+
* This used to mint one unconditionally, and for a rollout the deck joined
|
|
208
|
+
* partway through that event is a false statement. #683 made the falsehood
|
|
209
|
+
* visible rather than merely wrong: a root created by anything OTHER than a
|
|
210
|
+
* `SessionStart` is marked `synthetic` in the reducer — "the deck joined this
|
|
211
|
+
* session after it had already begun, so the start time, the prompt history and
|
|
212
|
+
* the early tool calls on this card are incomplete rather than empty" — and a
|
|
213
|
+
* Codex session that carries a minted `SessionStart` clears that marker and
|
|
214
|
+
* asserts a beginning nobody watched. The Claude side never had the problem: a
|
|
215
|
+
* deck that starts mid-session simply never receives a `SessionStart` hook for
|
|
216
|
+
* it, which is exactly the case the marker was built for.
|
|
217
|
+
*
|
|
218
|
+
* `sawBeginning` is the one fact that separates the two, and it is not a
|
|
219
|
+
* filesystem question. It is set where the tail cursor is: TRUE when this
|
|
220
|
+
* watcher opened the rollout at byte 0 and therefore holds every line the
|
|
221
|
+
* session ever wrote, FALSE when openCodexCursor, on scanRollouts'
|
|
222
|
+
* `firstRun`, skipped a pre-existing file's history by seeking to its current
|
|
223
|
+
* size. That is the same fact the event states, read off the only thing that
|
|
224
|
+
* actually knows it, and it is the same on Linux, macOS and Windows because no
|
|
225
|
+
* platform API is consulted.
|
|
226
|
+
*
|
|
227
|
+
* WHY NOT `fs.watch`, AND WHY NOT `birthtime` — both measured rather than
|
|
228
|
+
* assumed, on Node 22.14 / darwin, plus the documented behaviour elsewhere:
|
|
229
|
+
*
|
|
230
|
+
* • `fs.watch` on a path that does not exist throws ENOENT, and
|
|
231
|
+
* ~/.codex/sessions/YYYY/MM/DD does not exist until the day's first
|
|
232
|
+
* session — the same reason startCodexWatcher polls instead of watching.
|
|
233
|
+
* • a NON-recursive watch on ~/.codex/sessions reports only `rename:2026`
|
|
234
|
+
* when a rollout appears three levels below it; the file's creation is
|
|
235
|
+
* invisible to it.
|
|
236
|
+
* • `recursive: true` works here and on Windows, and on Linux only from a
|
|
237
|
+
* Node 20.x release — while this package declares `"node": ">=18"`, so on
|
|
238
|
+
* the OS Codex users most often run servers on it may simply not be there.
|
|
239
|
+
* • even where it works the event does not mean "created": writing the file
|
|
240
|
+
* and then rewriting it produced `rename:rollout.jsonl` BOTH times, and
|
|
241
|
+
* Node documents the event type as not guaranteed and `filename` as
|
|
242
|
+
* possibly null.
|
|
243
|
+
* • `stat().birthtimeMs` is real on APFS, but Node documents it as falling
|
|
244
|
+
* back to ctime or to the Unix epoch on filesystems that do not carry it —
|
|
245
|
+
* so on some Linux hosts every rollout would look newborn.
|
|
246
|
+
*
|
|
247
|
+
* WHAT ANOTHER DECK SEES. Nothing new: the fix REMOVES a line from
|
|
248
|
+
* events.jsonl, it does not add one or change a shape. A joined-late Codex
|
|
249
|
+
* session now reaches the shared log looking exactly like a joined-late Claude
|
|
250
|
+
* one — a root conjured by its first real event — which every deck, including
|
|
251
|
+
* one older than #683 that has no marker to light, has always been able to
|
|
252
|
+
* replay. The events that create the root instead (`UserPromptSubmit`,
|
|
253
|
+
* `PreToolUse`, `ModelObserved`, …) each carry `provider: "codex"`, `cwd` and
|
|
254
|
+
* `approval_policy` from `base` in codexObjToPayload, so nothing an older deck
|
|
255
|
+
* read off the `SessionStart` is lost with it.
|
|
256
|
+
*
|
|
257
|
+
* `rootOpened` still flips in both cases, and deliberately: it gates the
|
|
258
|
+
* per-batch AGENTS.md resolution in scanRollouts, which asks "is this session
|
|
259
|
+
* being drawn", not "did we announce it".
|
|
260
|
+
*/
|
|
261
|
+
function ensureCodexRoot(state, persist) {
|
|
262
|
+
if (state.rootOpened) return;
|
|
263
|
+
state.rootOpened = true;
|
|
264
|
+
if (!state.sawBeginning) return;
|
|
265
|
+
emitCodexEvent({ session_id: state.sid, cwd: state.cwd, provider: "codex", hook_event_name: "SessionStart" }, persist);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* A cursor for a rollout this watcher holds none for yet, recorded in
|
|
270
|
+
* codexFileState, and returned when this tick should go on to read from it.
|
|
271
|
+
* Null when it should not: the header cannot be read yet, so the file is
|
|
272
|
+
* retried next tick; the rollout runs outside this deck's workspace, so a
|
|
273
|
+
* skipping cursor is parked at its end; or this is the first scan, and the
|
|
274
|
+
* file's history is skipped.
|
|
275
|
+
*/
|
|
276
|
+
async function openCodexCursor(path, st, now, firstRun) {
|
|
277
|
+
// New file — read the header for sid + cwd, then decide whether to
|
|
278
|
+
// capture it. Skip files outside our workspace.
|
|
279
|
+
const header = await readCodexHeader(path);
|
|
280
|
+
if (!header || !header.sid) return null; // not ready yet — retry next tick
|
|
281
|
+
if (!codexCwdInWorkspace(header.cwd, codexWorkspace)) {
|
|
282
|
+
codexFileState.set(path, { offset: st.size, sid: header.sid, cwd: header.cwd, skip: true, sawBeginning: false, rootOpened: false, seenAt: now });
|
|
283
|
+
return null;
|
|
284
|
+
}
|
|
285
|
+
// Opened at byte 0, so every line this session ever wrote is about to
|
|
286
|
+
// be read: this watcher HAS its beginning, and ensureCodexRoot may say
|
|
287
|
+
// so. The `firstRun` branch below is the one case that takes it away.
|
|
288
|
+
//
|
|
289
|
+
// UNLESS THIS PROCESS HAS SEEN THE PATH BEFORE, in which case there is
|
|
290
|
+
// no beginning to claim and nothing to replay: the cursor was dropped
|
|
291
|
+
// by the TTL sweep and this is a RE-discovery. Parking at the current
|
|
292
|
+
// size costs whatever was appended while the tree was unreachable,
|
|
293
|
+
// which is the same trade `firstRun` already makes below — against
|
|
294
|
+
// re-emitting every prompt, every PreToolUse/PostToolUse and every
|
|
295
|
+
// UsageObserved in the file to every tab, and re-appending the lot to
|
|
296
|
+
// the shared events.jsonl if this deck is the elected writer (#981).
|
|
297
|
+
const fresh = !codexSeenEver.has(path);
|
|
298
|
+
const state = { offset: fresh ? 0 : st.size, sid: header.sid, cwd: header.cwd, skip: false, sawBeginning: fresh, rootOpened: false, seenAt: now };
|
|
299
|
+
codexFileState.set(path, state);
|
|
300
|
+
rememberCodexPath(path);
|
|
301
|
+
if (firstRun) {
|
|
302
|
+
// On startup, skip a pre-existing session's history entirely — no
|
|
303
|
+
// replay. Only future appends (a live session that keeps going) will
|
|
304
|
+
// lazily open the root via ensureCodexRoot.
|
|
305
|
+
//
|
|
306
|
+
// And it opens WITHOUT a `SessionStart` (#684). Seeking to the
|
|
307
|
+
// current size is precisely the admission that this deck did not
|
|
308
|
+
// watch the session begin, so it must not go on to emit the event
|
|
309
|
+
// that says it did — that is the input #683's joined-late marker is
|
|
310
|
+
// entitled to trust. A deck RESTARTING over a session that is still
|
|
311
|
+
// running lands here too, and correctly: the new process holds none
|
|
312
|
+
// of the old one's history either. If the log it replays at boot
|
|
313
|
+
// already contains a `SessionStart` this deck minted honestly in an
|
|
314
|
+
// earlier life, the root is rebuilt unmarked from that line and stays
|
|
315
|
+
// unmarked, because nothing here emits a second one to disturb it.
|
|
316
|
+
//
|
|
317
|
+
// NOT because the reducer would refuse to listen. It used to say so
|
|
318
|
+
// here — "the reducer only honours `synthetic` on the call that
|
|
319
|
+
// CREATES the node" — and that is false: `applySessionStart`, in
|
|
320
|
+
// src/web/session-lifecycle.ts, clears `root.synthetic` on EVERY
|
|
321
|
+
// `SessionStart`, deliberately, so that one arriving after the event
|
|
322
|
+
// that created the root retracts a marker the deck no longer
|
|
323
|
+
// deserves to be wearing (#677). Which makes the marker exactly as
|
|
324
|
+
// honest as the emitter above it, and is why the rule this branch
|
|
325
|
+
// states — do not emit a `SessionStart` for a beginning this process
|
|
326
|
+
// did not watch — is the whole guarantee rather than a belt beside a
|
|
327
|
+
// brace (#981).
|
|
328
|
+
state.offset = st.size;
|
|
329
|
+
state.sawBeginning = false;
|
|
330
|
+
return null;
|
|
331
|
+
}
|
|
332
|
+
return state;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Send on each complete line of one batch appended to a rollout, as the hook
|
|
337
|
+
* payload it translates to, opening the session's root before its first event.
|
|
338
|
+
* A line that changes the session's model is preceded by a ModelObserved.
|
|
339
|
+
* `persist` is the batch's one verdict — see emitCodexEvent.
|
|
340
|
+
*/
|
|
341
|
+
function emitCodexLines(state, consume, persist) {
|
|
342
|
+
for (const line of consume.split("\n")) {
|
|
343
|
+
if (!line) continue;
|
|
344
|
+
let obj;
|
|
345
|
+
try { obj = JSON.parse(line); } catch { continue; }
|
|
346
|
+
const prevModel = codexSessionModel.get(state.sid);
|
|
347
|
+
const payload = codexObjToPayload(obj, state.sid, state.cwd);
|
|
348
|
+
// If the model changed (turn_context/response_item), surface it.
|
|
349
|
+
const nowModel = codexSessionModel.get(state.sid);
|
|
350
|
+
if (nowModel && nowModel !== prevModel) {
|
|
351
|
+
ensureCodexRoot(state, persist);
|
|
352
|
+
emitCodexEvent({ session_id: state.sid, cwd: state.cwd, provider: "codex", hook_event_name: "ModelObserved", model: nowModel }, persist);
|
|
353
|
+
}
|
|
354
|
+
if (payload) {
|
|
355
|
+
ensureCodexRoot(state, persist);
|
|
356
|
+
emitCodexEvent(payload, persist);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Rollout files fall out of the newest-2-days listing and never come back,
|
|
363
|
+
* but their tail cursors used to live as long as the process did. Expire by
|
|
364
|
+
* "not seen for a while" rather than "absent from this listing": a single
|
|
365
|
+
* unreadable directory mid-scan would otherwise drop a live file's cursor,
|
|
366
|
+
* and re-adding it at offset 0 replays that entire rollout as fresh events.
|
|
367
|
+
*/
|
|
368
|
+
function sweepCodexCursors(now) {
|
|
369
|
+
for (const [p, s] of codexFileState) {
|
|
370
|
+
if (now - (s.seenAt ?? 0) > CODEX_STATE_TTL_MS) codexFileState.delete(p);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function codexScanOnce(firstRun) {
|
|
375
|
+
codexScan ??= scanRollouts(firstRun).finally(() => { codexScan = null; });
|
|
376
|
+
return codexScan;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* One scan that begins after this call, awaited — what the poll does every
|
|
381
|
+
* 1500ms, on demand.
|
|
382
|
+
*
|
|
383
|
+
* A scan already reading when this is called may have listed the tree before
|
|
384
|
+
* whatever the caller just changed, so it is waited out first and not counted.
|
|
385
|
+
* The suite waits on this rather than on the poll's clock: a case that needs
|
|
386
|
+
* "the watcher has looked since I wrote" used to sleep two polls for it, and a
|
|
387
|
+
* case that needs "and nothing more came" two polls more (#994).
|
|
388
|
+
*/
|
|
389
|
+
export async function scanCodexNow() {
|
|
390
|
+
if (codexScan) await codexScan;
|
|
391
|
+
await codexScanOnce(false);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
async function scanRollouts(firstRun) {
|
|
395
|
+
try {
|
|
396
|
+
const now = Date.now();
|
|
397
|
+
// Read at most once per scan, and only from the first rollout that actually
|
|
398
|
+
// has new bytes: most ticks read nothing and must not pay a directory
|
|
399
|
+
// listing for the answer to a question nothing is asking.
|
|
400
|
+
let decksRead = null;
|
|
401
|
+
const liveDecks = () => (decksRead ??= readLiveDecks());
|
|
402
|
+
const files = await listRecentCodexRollouts();
|
|
403
|
+
for (const path of files) {
|
|
404
|
+
let st;
|
|
405
|
+
try { st = await stat(path); } catch { continue; }
|
|
406
|
+
let state = codexFileState.get(path);
|
|
407
|
+
if (state) state.seenAt = now;
|
|
408
|
+
|
|
409
|
+
if (!state) {
|
|
410
|
+
state = await openCodexCursor(path, st, now, firstRun);
|
|
411
|
+
if (!state) continue;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
if (state.skip) { state.offset = st.size; continue; }
|
|
415
|
+
// A cursor sitting PAST the end of the file is a different thing from
|
|
416
|
+
// "nothing new", and the two used to collapse into one `continue` — where
|
|
417
|
+
// the Claude scanner beside this one starts the file over (scanTranscript).
|
|
418
|
+
// Not reachable today: rollouts are append-only and uniquely named, and
|
|
419
|
+
// readAppendedLines cannot advance a cursor past `size`. It is resynced
|
|
420
|
+
// FORWARD rather than back to zero because re-reading a rollout from the
|
|
421
|
+
// beginning is precisely the full replay #981 is about.
|
|
422
|
+
if (st.size < state.offset) state.offset = st.size;
|
|
423
|
+
if (st.size <= state.offset) continue;
|
|
424
|
+
|
|
425
|
+
// Same bounded reader the Claude transcripts use. A rollout is not a
|
|
426
|
+
// caller-chosen path, so this is not the #674 exposure — but the
|
|
427
|
+
// allocation was the size of the appended bytes here too, and one chunk
|
|
428
|
+
// per tick is more than any real rollout appends in a tick.
|
|
429
|
+
const { text: consume, advanced } = await readAppendedLines(path, state, st.size);
|
|
430
|
+
if (advanced === 0) continue; // no complete line yet
|
|
431
|
+
|
|
432
|
+
// Decided per file rather than per line: which decks are up, and which of
|
|
433
|
+
// them tail this rollout, cannot change inside one batch of appended
|
|
434
|
+
// lines, and the answer must be the same for every event in it — a root
|
|
435
|
+
// written by one deck and its tool calls by another is worse than either.
|
|
436
|
+
const persist = !eventLogPath()
|
|
437
|
+
|| writesCodexLog({ decks: await liveDecks(), pid: process.pid, cwd: state.cwd });
|
|
438
|
+
|
|
439
|
+
emitCodexLines(state, consume, persist);
|
|
440
|
+
|
|
441
|
+
// Once per batch of appended lines rather than once per line — the scan
|
|
442
|
+
// throttles itself per session, but the cheapest call is the one that is
|
|
443
|
+
// never made, and a batch can be hundreds of lines. Gated on the root
|
|
444
|
+
// existing so a session the deck has decided not to draw does not cost a
|
|
445
|
+
// directory walk, and repeated rather than done once at root creation so
|
|
446
|
+
// an AGENTS.md written after the session started is still found (#399).
|
|
447
|
+
//
|
|
448
|
+
// Carries the same `persist` verdict as every emit in the loop above, and
|
|
449
|
+
// for the same reason: this is one more event about this batch of appended
|
|
450
|
+
// lines, and a batch whose roots and tool calls went to one deck's log
|
|
451
|
+
// while its memory list went to every deck's is the split the election
|
|
452
|
+
// exists to prevent (#447).
|
|
453
|
+
if (state.rootOpened) maybeResolveCodexMemory(state.sid, state.cwd, persist);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// AN EMPTY LISTING IS NOT EVIDENCE THAT ANYTHING WENT AWAY, and skipping
|
|
457
|
+
// the sweep on those ticks is the other half of sweepCodexCursors'
|
|
458
|
+
// argument. The "not seen for a while" clock was meant to ride out a
|
|
459
|
+
// directory that is unreadable for a moment, and it did — but it ran on
|
|
460
|
+
// ticks where `seenAt` had been refreshed for NOBODY, because
|
|
461
|
+
// walkRolloutDays swallows its readdir error at every level and answers
|
|
462
|
+
// `[]` rather than throwing. Ten minutes of an unreachable $CODEX_HOME — a
|
|
463
|
+
// network or removable volume, an encrypted home not yet unlocked, a
|
|
464
|
+
// permission change — and every cursor was gone, which is the whole window
|
|
465
|
+
// the clock was sized to survive (#981).
|
|
466
|
+
//
|
|
467
|
+
// Nothing is lost by waiting: an empty listing means no file can be read
|
|
468
|
+
// this tick anyway, and a tree that really is empty stays empty, so the
|
|
469
|
+
// first tick that lists anything at all sweeps what is genuinely stale.
|
|
470
|
+
if (files.length) sweepCodexCursors(now);
|
|
471
|
+
} catch {
|
|
472
|
+
/* swallow — watcher must never crash the server */
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
export function startCodexWatcher(workspace) {
|
|
477
|
+
codexWorkspace = workspace ?? "";
|
|
478
|
+
// Deliberately not gated on CODEX_SESSIONS_DIR existing. `codex login`
|
|
479
|
+
// creates ~/.codex, but sessions/ only appears when the first session
|
|
480
|
+
// starts — and rollout tailing is the only Codex capture path there is, so
|
|
481
|
+
// bailing out here meant a fresh install had to restart the deck before any
|
|
482
|
+
// Codex agent ever showed up, while the banner claimed to be watching.
|
|
483
|
+
// listRecentCodexRollouts returns [] while the directory is missing, so the
|
|
484
|
+
// poll below is a cheap no-op that doubles as the existence re-check. A
|
|
485
|
+
// filesystem watch is no help: fs.watch on a missing path throws, and
|
|
486
|
+
// watching the parent recursively is macOS/Windows-only.
|
|
487
|
+
//
|
|
488
|
+
// Initial catalog: park a cursor at the end of every rollout already on disk,
|
|
489
|
+
// skipping its history, then poll for new lines. The `true` is what tells
|
|
490
|
+
// codexScanOnce that these files pre-date this deck, and so that the roots
|
|
491
|
+
// they eventually open must arrive without a `SessionStart` (#684).
|
|
492
|
+
codexScanOnce(true).catch(() => {});
|
|
493
|
+
codexWatchTimer = setInterval(() => { codexScanOnce(false).catch(() => {}); }, 1500);
|
|
494
|
+
if (codexWatchTimer.unref) codexWatchTimer.unref();
|
|
495
|
+
return codexWatchTimer;
|
|
496
|
+
}
|