context-carwash 0.2.0 → 0.2.2

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.
@@ -1,44 +1,60 @@
1
1
  import { existsSync, writeFileSync } from 'node:fs';
2
2
  import { packageVersion } from '../install/install.mjs';
3
3
  import { startNotice, versionLine } from '../install/notice.mjs';
4
+ import { isNewer } from '../install/update.mjs';
4
5
  import { DEFAULT_LEVEL, capFor, estimateTokens, isLevel } from '../levels.mjs';
5
6
  import { installRecordPath, welcomedPath } from '../paths.mjs';
6
- import { recordDelivery, recordWash, refreshLedger, totals } from '../stats/ledger.mjs';
7
- import { statsReport } from '../stats/report.mjs';
8
- import { budgetNote, composeContext, describe, helpMessage, loadedMessage, parseReply, pointerMessage, readyMessage, retryNote, stalenessLine, welcomeMessage } from './brief.mjs';
7
+ import { recordDelivery, recordWash, refreshLedger } from '../stats/ledger.mjs';
8
+ import { colorDepth } from '../statusline/color.mjs';
9
+ import { currentTotals, statsReport } from '../stats/report.mjs';
10
+ import { budgetNote, composeContext, describe, helpMessage, loadedMessage, parseReply, pointerMessage, readyMessage, replyMeterMessage, retryNote, stalenessLine, welcomeMessage } from './brief.mjs';
11
+ import { isTool, toolInfo, toolOf } from '../tools.mjs';
9
12
  import { repoState, worktreeRoot } from './git.mjs';
10
- import { currentProcess, isRunning, sameProcess } from './process.mjs';
13
+ import { codexProcess, codexUnattended, currentProcess, isRunning, sameProcess } from './process.mjs';
14
+ import { readCodexReplyUsage, readCodexUsage, readCodexVersion } from './rollout.mjs';
11
15
  import { currentWash, endWash, loadUsage, startWash } from './session.mjs';
12
16
  import { AUTO_LOAD_MS, addRecord, claim, findRecords, purge, readyRecords, removeRecord, reopen, updateRecord } from './store.mjs';
13
17
 
14
- // Carwash's three Claude Code hooks:
18
+ // Carwash's three hooks, in Claude Code and in Codex:
15
19
  //
16
- // UserPromptExpansion /wash was typed. A level starts a wash and tells
17
- // Claude the size limit; discard, load, stats and help are
18
- // handled here and never reach Claude.
19
- // Stop Claude finished replying. If that reply is the brief,
20
- // save it.
20
+ // UserPromptExpansion /wash was typed (Claude Code). In Codex, where $wash
21
+ // UserPromptSubmit is a skill, every prompt comes through
22
+ // UserPromptSubmit and only one starting with $wash is
23
+ // ours. A level starts a wash and tells the model the
24
+ // size limit; discard, load, stats and help are handled
25
+ // here and never reach the model.
26
+ // Stop The model finished replying. If that reply is the
27
+ // brief, save it. Otherwise, in Codex, show the meter.
21
28
  // SessionStart A session began. If a brief is meant for it, hand the
22
29
  // brief over. The first one after install says hello.
23
30
  // It also starts the daily update check, in the
24
31
  // background, and passes on what the last one found.
25
32
  //
26
- // Each handler returns the JSON to print for Claude Code, or null to stay out
27
- // of the way. None of them ever clears, restarts, or runs anything.
33
+ // Each handler returns the JSON to print for the tool, or null to stay out of
34
+ // the way. None of them ever clears, restarts, or runs anything. Both tools
35
+ // read the same JSON fields here, apart from how a Stop asks for another try.
28
36
 
29
- const USAGE = 'Usage: /wash [light | mid | detailed], /wash discard [id], /wash load [id], /wash stats, /wash help';
37
+ const usageLine = (tool) => {
38
+ const wash = toolInfo(tool).wash;
39
+ return `Usage: ${wash} [light | mid | detailed], ${wash} discard [id], ${wash} load [id], ${wash} stats, ${wash} help`;
40
+ };
30
41
 
31
42
  // How many times to ask for a shorter brief before keeping an oversized one.
32
43
  const MAX_REWRITES = 2;
33
44
 
34
- // Stops the command before it reaches Claude: no model turn, no cost. Claude
35
- // Code shows the reason under "operation blocked by hook", so it names us.
45
+ // Stops the command before it reaches the model: no model turn, no cost.
46
+ // Claude Code shows the reason under "operation blocked by hook", Codex under
47
+ // "Blocked by hook", so it names us.
36
48
  const block = (reason) => ({ decision: 'block', reason: `Carwash: ${reason}` });
37
49
 
38
50
  // A `claude -p` run or other unattended session has nobody to carry on a
39
51
  // conversation, so it must not use up a brief meant for a person.
40
52
  const unattended = (env) => env.CLAUDE_CODE_SESSION_ATTENDED === '0' || /^sdk/.test(env.CLAUDE_CODE_ENTRYPOINT ?? '');
41
53
 
54
+ // Claude Code gives each prompt an id that every Stop of its turn repeats;
55
+ // Codex does the same with its turn id.
56
+ const promptIdOf = (payload) => payload.prompt_id ?? payload.turn_id ?? null;
57
+
42
58
  // The savings ledger is bookkeeping: if it fails, the brief still has to work.
43
59
  function quietly(ctx, fn) {
44
60
  try {
@@ -48,33 +64,55 @@ function quietly(ctx, fn) {
48
64
  }
49
65
  }
50
66
 
67
+ // A question asked at most once per hook call.
68
+ const once = (ask) => {
69
+ let answer;
70
+ return () => (answer ??= ask());
71
+ };
72
+
51
73
  const cwdOf = (payload) => (typeof payload.cwd === 'string' && payload.cwd ? payload.cwd : process.cwd());
52
74
 
53
75
  // --- /wash ---------------------------------------------------------------
54
76
 
55
77
  function onExpansion(payload, ctx) {
56
78
  if (String(payload.command_name ?? '').replace(/^\//, '') !== 'wash') return null;
57
- const [verb = DEFAULT_LEVEL, ref] = String(payload.command_args ?? '').trim().toLowerCase().split(/\s+/).filter(Boolean);
58
- if (isLevel(verb)) return begin(payload, verb, ctx);
79
+ return washCommand(payload, payload.command_args, 'UserPromptExpansion', ctx);
80
+ }
81
+
82
+ // Codex runs this for every prompt, so anything that is not $wash returns at
83
+ // once, before any file or process is looked at.
84
+ const DOLLAR_WASH = /^\s*\$wash(?![\w-])([\s\S]*)$/;
85
+
86
+ function onPromptSubmit(payload, ctx) {
87
+ const found = typeof payload.prompt === 'string' ? DOLLAR_WASH.exec(payload.prompt) : null;
88
+ return found ? washCommand(payload, found[1], 'UserPromptSubmit', ctx) : null;
89
+ }
90
+
91
+ function washCommand(payload, args, event, ctx) {
92
+ const [verb = DEFAULT_LEVEL, ref] = String(args ?? '').trim().toLowerCase().split(/\s+/).filter(Boolean);
93
+ if (isLevel(verb)) return begin(payload, verb, event, ctx);
59
94
  if (verb === 'discard') return block(discard(payload, ref, ctx));
60
95
  if (verb === 'load') return block(load(payload, ref, ctx));
61
- if (verb === 'stats') return block(statsReport(totals(refreshLedger(ctx.env, { now: ctx.now, force: true }), ctx.now), { inClaude: true }));
62
- if (verb === 'help') return block(helpMessage(loadUsage(ctx.env, payload.session_id)?.used ?? null, versionLine(ctx.version, ctx.env)));
63
- return block(`/wash does not know "${verb}". ${USAGE}`);
96
+ if (verb === 'stats') return block(statsReport(currentTotals(ctx.env, ctx.now), { inClaude: true }));
97
+ if (verb === 'help') return block(helpMessage(ctx.usage(payload)?.used ?? null, versionLine(ctx.version, ctx.env), ctx.tool));
98
+ return block(`${toolInfo(ctx.tool).wash} does not know "${verb}". ${usageLine(ctx.tool)}`);
64
99
  }
65
100
 
66
- function begin(payload, level, { env, now }) {
67
- const usage = loadUsage(env, payload.session_id);
101
+ function begin(payload, level, event, ctx) {
102
+ const { env, now } = ctx;
103
+ const usage = ctx.usage(payload);
68
104
  const used = usage?.used ?? null;
69
105
  const cap = capFor(level, used);
70
- startWash(env, payload.session_id, { promptId: payload.prompt_id ?? null, level, cap, used, size: usage?.size ?? null, startedAt: now, rewrites: 0 });
71
- return { hookSpecificOutput: { hookEventName: 'UserPromptExpansion', additionalContext: budgetNote(level, cap, used) } };
106
+ startWash(env, payload.session_id, { promptId: promptIdOf(payload), level, cap, used, size: usage?.size ?? null, startedAt: now, rewrites: 0 });
107
+ return { hookSpecificOutput: { hookEventName: event, additionalContext: budgetNote(level, cap, used) } };
72
108
  }
73
109
 
74
- // Briefs waiting for the folder this session is in, other than its own.
75
- function waitingHere(payload, { env, now, me }) {
110
+ // Briefs waiting for the folder this session is in, other than its own. Only
111
+ // ones written in the same tool: a brief from the other one is used only when
112
+ // asked for by its id.
113
+ function waitingHere(payload, { env, now, me, tool }) {
76
114
  const here = worktreeRoot(cwdOf(payload));
77
- return readyRecords(env, now).filter((record) => record.source?.worktree === here && !sameProcess(record.owner, me));
115
+ return readyRecords(env, now).filter((record) => record.source?.worktree === here && toolOf(record) === tool && !sameProcess(record.owner, me));
78
116
  }
79
117
 
80
118
  function discard(payload, ref, ctx) {
@@ -93,7 +131,7 @@ function discard(payload, ref, ctx) {
93
131
  return `discarded brief ${describe(mine[0], now)}. /clear is a normal clear again.`;
94
132
  }
95
133
  const others = waitingHere(payload, ctx);
96
- const hint = others.length ? ` Waiting for this folder: ${others.map((record) => describe(record, now)).join(' · ')}. To drop one: /wash discard <id>.` : '';
134
+ const hint = others.length ? ` Waiting for this folder: ${others.map((record) => describe(record, now)).join(' · ')}. To drop one: ${toolInfo(ctx.tool).wash} discard <id>.` : '';
97
135
  return `no brief is ready in this session, so there was nothing to discard.${hint}`;
98
136
  }
99
137
 
@@ -119,49 +157,72 @@ function load(payload, ref, ctx) {
119
157
  } else {
120
158
  const others = waitingHere(payload, ctx);
121
159
  if (!others.length) return 'no briefs are waiting for this folder.';
122
- if (others.length > 1) return `more than one brief is waiting for this folder: ${others.map((record) => describe(record, now)).join(' · ')}. Pick one: /wash load <id>.`;
160
+ if (others.length > 1) return `more than one brief is waiting for this folder: ${others.map((record) => describe(record, now)).join(' · ')}. Pick one: ${toolInfo(ctx.tool).wash} load <id>.`;
123
161
  match = { record: others[0], consumed: false };
124
162
  }
125
163
  const record = match.consumed ? reopen(env, match.record) : match.record;
126
164
  if (!record) return `brief ${match.record.id} could not be loaded: another session took it just now.`;
127
165
  const owned = adopt(record, payload, ctx);
128
166
  const elsewhere = owned.source?.worktree && owned.source.worktree !== worktreeRoot(cwdOf(payload)) ? ` Note: it was written in a different folder, ${owned.source.worktree}.` : '';
129
- return `brief ${describe(owned, now)} is ready ✓ Type /clear to start fresh with it.${elsewhere}`;
167
+ return `brief ${describe(owned, now)} is ready ✓ ${toolInfo(ctx.tool).afterClear}${elsewhere}`;
130
168
  }
131
169
 
132
170
  // --- Stop ----------------------------------------------------------------
133
171
 
172
+ // Where the status line cannot show the meter (Codex), it follows each reply
173
+ // instead. It goes to the user only, never to the model, so it costs nothing.
174
+ function replyMeter(payload, ctx) {
175
+ if (!toolInfo(ctx.tool).replyMeter) return null;
176
+ const usage = ctx.replyUsage(payload);
177
+ if (!usage) return null;
178
+ const depth = codexShowsColor(ctx.codexVersion(payload)) ? colorDepth(ctx.env) : 'none';
179
+ return { systemMessage: replyMeterMessage(usage, ctx.tool, depth) };
180
+ }
181
+
182
+ // Codex draws colour codes in a hook's message from 0.162.0 on (openai/codex
183
+ // #50359). Before that they would show as stray characters, so an unknown
184
+ // version gets plain text.
185
+ const COLOR_SINCE = '0.162.0';
186
+ const codexShowsColor = (version) => /^\d+\.\d+\.\d+$/.test(version ?? '') && !isNewer(COLOR_SINCE, version);
187
+
134
188
  function onStop(payload, ctx) {
135
189
  const { env, now, me } = ctx;
136
190
  const sessionId = payload.session_id;
137
191
  const wash = currentWash(env, sessionId, now);
138
- if (!wash) return null;
192
+ if (!wash) return replyMeter(payload, ctx);
139
193
 
140
- // Claude Code gives a prompt and every Stop of its turn the same id. A Stop
141
- // for some other prompt means the wash turn ended without one of its own:
142
- // it was interrupted.
143
- if (wash.promptId && payload.prompt_id && wash.promptId !== payload.prompt_id) {
194
+ // Claude Code gives a prompt and every Stop of its turn the same id, and
195
+ // Codex its turn id, including the Stops after asking for a shorter brief. A
196
+ // Stop for some other prompt means the wash turn ended without one of its
197
+ // own: it was interrupted.
198
+ const promptId = promptIdOf(payload);
199
+ if (wash.promptId && promptId && wash.promptId !== promptId) {
144
200
  endWash(env, sessionId);
145
- return null;
201
+ return replyMeter(payload, ctx);
146
202
  }
147
203
 
148
204
  const reply = parseReply(payload.last_assistant_message);
149
205
  if (!reply) {
150
206
  endWash(env, sessionId);
151
- return { systemMessage: 'Carwash: that reply was not a brief, so nothing was saved. Run /wash again to retry.' };
207
+ return { systemMessage: `Carwash: that reply was not a brief, so nothing was saved. Run ${toolInfo(ctx.tool).wash} again to retry.` };
152
208
  }
153
209
 
154
210
  const tokens = estimateTokens(reply.text);
155
211
  const overCap = Boolean(wash.cap) && tokens > wash.cap;
156
212
  if (overCap && wash.rewrites < MAX_REWRITES) {
157
213
  startWash(env, sessionId, { ...wash, rewrites: wash.rewrites + 1 });
158
- return { hookSpecificOutput: { hookEventName: 'Stop', additionalContext: retryNote(wash.level, wash.cap, tokens) } };
214
+ const note = retryNote(wash.level, wash.cap, tokens);
215
+ // Codex continues the turn with a Stop's block reason as the next prompt.
216
+ if (ctx.tool === 'codex') return { decision: 'block', reason: note };
217
+ return { hookSpecificOutput: { hookEventName: 'Stop', additionalContext: note } };
159
218
  }
160
219
 
161
220
  const cwd = cwdOf(payload);
162
221
  const record = addRecord(
163
222
  env,
164
223
  {
224
+ // Briefs from Claude Code carry no tool, as before Codex support.
225
+ ...(ctx.tool === 'claude' ? {} : { tool: ctx.tool }),
165
226
  level: wash.level,
166
227
  title: reply.title,
167
228
  tokens,
@@ -192,10 +253,10 @@ function deliver(record, payload, ctx) {
192
253
  const { env, now } = ctx;
193
254
  // Another session may have taken it between the listing and now.
194
255
  if (!claim(env, record, payload.session_id, now)) return null;
195
- quietly(ctx, () => recordDelivery(env, record, payload, now));
256
+ quietly(ctx, () => recordDelivery(env, record, payload, now, ctx.tool));
196
257
  const stale = stalenessLine(record.source, repoState(cwdOf(payload)));
197
258
  return {
198
- systemMessage: loadedMessage(record, now, stale),
259
+ systemMessage: loadedMessage(record, now, stale, ctx.tool),
199
260
  hookSpecificOutput: { hookEventName: 'SessionStart', additionalContext: composeContext(record, now, stale) },
200
261
  };
201
262
  }
@@ -204,22 +265,27 @@ function deliver(record, payload, ctx) {
204
265
  // marker is the claim, so two sessions starting together cannot both say it.
205
266
  // Uninstall deletes the marker with the rest of ~/.carwash, so a reinstall
206
267
  // says it again. Without an install record there is nobody to welcome.
207
- function welcome({ env, now }) {
208
- if (!existsSync(installRecordPath(env))) return null;
268
+ function welcome(ctx) {
269
+ const { env, now, tool } = ctx;
270
+ if (!existsSync(installRecordPath(env)) || existsSync(welcomedPath(env, tool))) return null;
271
+ if (ctx.unattended()) return null;
209
272
  try {
210
- writeFileSync(welcomedPath(env), `${new Date(now).toISOString()}\n`, { flag: 'wx', mode: 0o600 });
273
+ writeFileSync(welcomedPath(env, tool), `${new Date(now).toISOString()}\n`, { flag: 'wx', mode: 0o600 });
211
274
  } catch {
212
275
  // Already shown, or nowhere to note it: better silent than said every time.
213
276
  return null;
214
277
  }
215
- return { systemMessage: welcomeMessage() };
278
+ return { systemMessage: welcomeMessage(tool) };
216
279
  }
217
280
 
218
281
  function onSessionStart(payload, ctx) {
219
282
  // Only a brand-new conversation gets a brief; a resumed, compacted or
220
283
  // forked one already has its own history.
221
284
  if (payload.source !== 'startup' && payload.source !== 'clear') return null;
222
- if (unattended(ctx.env)) return null;
285
+ // Claude Code says in its environment whether anyone is there. Codex has to
286
+ // be asked through its command line, which on Windows takes a PowerShell
287
+ // run, so it is asked only once a brief or the welcome is at stake.
288
+ if (ctx.tool === 'claude' && ctx.unattended()) return null;
223
289
  // The welcome waits for a start that has nothing else to say.
224
290
  const result = briefAtStart(payload, ctx) ?? welcome(ctx);
225
291
  quietly(ctx, () => ctx.checkUpdates?.());
@@ -241,7 +307,7 @@ function briefAtStart(payload, ctx) {
241
307
  const named = ready.find((record) => record.id === env.CARWASH_PENDING);
242
308
  return named ? deliver(named, payload, ctx) : null;
243
309
  }
244
- if (!ready.length) return null;
310
+ if (!ready.length || ctx.unattended()) return null;
245
311
 
246
312
  if (payload.source === 'clear') {
247
313
  // /clear keeps the process, so the brief this terminal asked for is the
@@ -261,46 +327,63 @@ function briefAtStart(payload, ctx) {
261
327
 
262
328
  // A new process picks up a brief left for its folder, but only when there is
263
329
  // no doubt which one is meant. Otherwise it says what is waiting and loads
264
- // nothing: the wrong context is worse than none.
330
+ // nothing: the wrong context is worse than none. A brief written in the other
331
+ // tool is not picked up this way.
265
332
  const here = worktreeRoot(cwdOf(payload));
266
- const waiting = ready.filter((record) => record.source?.worktree === here);
333
+ const waiting = ready.filter((record) => record.source?.worktree === here && toolOf(record) === ctx.tool);
267
334
  if (!waiting.length) return null;
268
335
  let why = null;
269
336
  if (waiting.length > 1) why = 'there is more than one';
270
337
  else if (now - Date.parse(waiting[0].createdAt) > AUTO_LOAD_MS) why = 'it is over 12 hours old';
271
338
  else if (ctx.isRunning(waiting[0].owner)) why = 'the session that wrote it is still open';
272
- return why ? { systemMessage: pointerMessage(waiting, now, why) } : deliver(waiting[0], payload, ctx);
339
+ return why ? { systemMessage: pointerMessage(waiting, now, why, ctx.tool) } : deliver(waiting[0], payload, ctx);
273
340
  }
274
341
 
275
342
  // --- entry point -----------------------------------------------------------
276
343
 
277
- // Handle one hook call. `input` is the JSON Claude Code sends on stdin.
344
+ // Handle one hook call. `input` is the JSON the tool sends on stdin, and
345
+ // `tool` says which tool that is: 'claude' (Claude Code) or 'codex'.
278
346
  //
279
347
  // `checkUpdates` starts the daily update check. Only the real command passes
280
- // it, so tests never go online.
281
- export function runHook({ input, env = process.env, platform = process.platform, now = Date.now(), me, running, onError, checkUpdates, version = packageVersion() } = {}) {
348
+ // it, so tests never go online. `unattended` and `usage` stand in for asking
349
+ // the system, in tests.
350
+ export function runHook({ input, tool = 'claude', env = process.env, platform = process.platform, now = Date.now(), me, running, onError, checkUpdates, version = packageVersion(), unattended: isUnattended, usage, replyUsage } = {}) {
282
351
  let payload;
283
352
  try {
284
353
  payload = JSON.parse(input);
285
354
  } catch {
286
355
  return null;
287
356
  }
288
- if (!payload || typeof payload !== 'object') return null;
357
+ if (!payload || typeof payload !== 'object' || !isTool(tool)) return null;
358
+ const codex = tool === 'codex';
359
+ const self = me ?? (codex ? codexProcess(platform) : currentProcess(env, platform));
289
360
  const ctx = {
361
+ tool,
290
362
  env,
291
363
  now,
292
- me: me ?? currentProcess(env, platform),
364
+ me: self,
293
365
  isRunning: running ?? ((owner) => isRunning(owner, platform)),
294
366
  onError,
295
367
  checkUpdates,
296
368
  version,
369
+ unattended: once(isUnattended ?? (() => (codex ? codexUnattended(self.args) : unattended(env)))),
370
+ // How much context the session is using. Claude Code's status line notes
371
+ // it as it draws the meter; Codex writes it into its session file.
372
+ usage: usage ?? ((p) => (codex ? readCodexUsage(p.transcript_path) : loadUsage(env, p.session_id))),
373
+ // The same once a reply has ended, waiting briefly for Codex to note it.
374
+ replyUsage: replyUsage ?? ((p) => readCodexReplyUsage(p.transcript_path)),
375
+ codexVersion: (p) => readCodexVersion(p.transcript_path),
297
376
  };
298
- switch (payload.hook_event_name) {
299
- case 'UserPromptExpansion':
377
+ switch (`${tool}:${payload.hook_event_name}`) {
378
+ case 'claude:UserPromptExpansion':
300
379
  return onExpansion(payload, ctx);
301
- case 'Stop':
380
+ case 'codex:UserPromptSubmit':
381
+ return onPromptSubmit(payload, ctx);
382
+ case 'claude:Stop':
383
+ case 'codex:Stop':
302
384
  return onStop(payload, ctx);
303
- case 'SessionStart':
385
+ case 'claude:SessionStart':
386
+ case 'codex:SessionStart':
304
387
  return onSessionStart(payload, ctx);
305
388
  default:
306
389
  return null;
@@ -40,6 +40,117 @@ export function currentProcess(env = process.env, platform = process.platform) {
40
40
  };
41
41
  }
42
42
 
43
+ // A process's parent and its command line, or null where it cannot be told or
44
+ // the process has gone. On macOS the arguments come back joined by spaces, so
45
+ // an argument that contains a space reads as several.
46
+ export function processInfo(pid, platform = process.platform) {
47
+ try {
48
+ if (platform === 'win32') return windowsInfo(pid);
49
+ if (platform === 'linux') {
50
+ const stat = readFileSync(`/proc/${pid}/stat`, 'utf8');
51
+ const ppid = Number.parseInt(stat.slice(stat.lastIndexOf(')') + 2).split(' ')[1], 10);
52
+ const args = readFileSync(`/proc/${pid}/cmdline`, 'utf8').split('\0').filter(Boolean);
53
+ return Number.isInteger(ppid) ? { ppid, args } : null;
54
+ }
55
+ const result = spawnSync('ps', ['-o', 'ppid=,args=', '-p', String(pid)], { encoding: 'utf8', timeout: 1000 });
56
+ const found = result.status === 0 && /^\s*(\d+)\s+(.*\S)/.exec(result.stdout);
57
+ return found ? { ppid: Number(found[1]), args: found[2].split(/\s+/) } : null;
58
+ } catch {
59
+ return null;
60
+ }
61
+ }
62
+
63
+ // Windows has no quick `ps`, so one PowerShell run reads a process and the
64
+ // few above it, and the answers are kept for the rest of this hook call. It is
65
+ // slow, so Codex's hooks only ask when a brief is at stake.
66
+ const windowsSeen = new Map();
67
+ function windowsInfo(pid) {
68
+ if (windowsSeen.has(pid)) return windowsSeen.get(pid);
69
+ windowsSeen.set(pid, null);
70
+ // [wmi] reads straight from WMI, without loading the CIM cmdlets first.
71
+ const script =
72
+ `$p=${Number(pid)};$o=@();for($i=0;$i -lt 4 -and $p;$i++){` +
73
+ 'try{$w=[wmi]"Win32_Process.Handle=\'$p\'"}catch{break};' +
74
+ '$o+=[pscustomobject]@{pid=[int]$p;ppid=[int]$w.ParentProcessId;name=[string]$w.Name;cmd=[string]$w.CommandLine};$p=$w.ParentProcessId};' +
75
+ 'ConvertTo-Json -Compress -InputObject @($o)';
76
+ // A cold PowerShell can take seconds; the hooks allow 20.
77
+ const result = spawnSync('powershell', ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', script], { encoding: 'utf8', timeout: 10_000, windowsHide: true });
78
+ if (result.status !== 0) return null;
79
+ for (const row of [JSON.parse(result.stdout)].flat()) {
80
+ if (!Number.isInteger(row?.pid)) continue;
81
+ // The program, then the words after it; the program's path may be quoted.
82
+ const rest = String(row.cmd ?? '').replace(/^\s*("[^"]*"|\S+)\s*/, '');
83
+ windowsSeen.set(row.pid, { ppid: row.ppid, args: [row.name || 'unknown', ...rest.split(/\s+/).filter(Boolean)] });
84
+ }
85
+ return windowsSeen.get(pid);
86
+ }
87
+
88
+ const SHELLS = new Set(['sh', 'bash', 'zsh', 'dash', 'ksh', 'mksh', 'fish', 'tcsh', 'csh', 'nu', 'pwsh', 'powershell', 'cmd']);
89
+ const isShell = (program) => SHELLS.has(String(program ?? '').split(/[\\/]/).pop().replace(/^-/, '').replace(/\.exe$/i, '').toLowerCase());
90
+
91
+ // The Codex process a hook is running under. Like Claude Code, it keeps one
92
+ // process across /clear. Codex starts each hook through a shell: on macOS and
93
+ // Linux the user's, which usually hands over to the command, so Codex is the
94
+ // parent; on Windows cmd.exe, which stays in between. Where a shell is the
95
+ // parent, Codex is the shell's parent. Nothing is looked up until it is asked
96
+ // for: the hook that runs on every prompt mostly never needs it. Claude Code's
97
+ // CLAUDE_PID is ignored, since Codex may have been started from a terminal
98
+ // inside Claude Code.
99
+ export function codexProcess(platform = process.platform, lookup = processInfo) {
100
+ let pid;
101
+ let info;
102
+ let start;
103
+ const resolve = () => {
104
+ if (pid !== undefined) return;
105
+ pid = process.ppid;
106
+ info = lookup(pid, platform);
107
+ for (let hops = 0; hops < 2 && info && isShell(info.args[0]) && info.ppid > 1; hops++) {
108
+ pid = info.ppid;
109
+ info = lookup(pid, platform);
110
+ }
111
+ };
112
+ return {
113
+ get pid() {
114
+ resolve();
115
+ return pid;
116
+ },
117
+ // Its command line, when it could be read.
118
+ get args() {
119
+ resolve();
120
+ return info?.args ?? null;
121
+ },
122
+ get start() {
123
+ if (start === undefined) start = processStart(this.pid, platform);
124
+ return start;
125
+ },
126
+ };
127
+ }
128
+
129
+ // Codex options that take a value, so the word after one is not a subcommand.
130
+ const CODEX_VALUE_OPTIONS = new Set(['-c', '--config', '--enable', '--disable', '--remote', '--remote-auth-token-env', '-i', '--image', '-m', '--model', '--local-provider', '-p', '--profile', '-s', '--sandbox', '-a', '--ask-for-approval', '-C', '--cd', '--add-dir']);
131
+
132
+ // The first word of a Codex command line that is not an option: a subcommand
133
+ // such as `exec`, or the opening prompt.
134
+ export function codexSubcommand(args) {
135
+ for (let i = 1; i < (args?.length ?? 0); i++) {
136
+ const arg = args[i];
137
+ if (arg === '--') return null;
138
+ if (arg.startsWith('-')) {
139
+ if (!arg.includes('=') && CODEX_VALUE_OPTIONS.has(arg)) i++;
140
+ continue;
141
+ }
142
+ return arg;
143
+ }
144
+ return null;
145
+ }
146
+
147
+ // Whether a Codex command line is a script's run (`codex exec`, `codex
148
+ // review`) rather than a person's session. Unknown counts as a person.
149
+ export function codexUnattended(args) {
150
+ const command = codexSubcommand(args);
151
+ return command === 'exec' || command === 'e' || command === 'review';
152
+ }
153
+
43
154
  // Whether a brief's owner is this very process.
44
155
  export function sameProcess(owner, me) {
45
156
  if (!owner || owner.pid !== me.pid) return false;
@@ -0,0 +1,111 @@
1
+ import { closeSync, fstatSync, openSync, readSync } from 'node:fs';
2
+
3
+ // Codex keeps each session in a "rollout" file: one JSON object per line. Codex
4
+ // says the format is not a stable interface, so everything here reads it
5
+ // defensively and anything unexpected is simply not counted.
6
+ //
7
+ // After each request to the model Codex writes a token_count event, whose
8
+ // last_token_usage.input_tokens is everything that request sent: the context
9
+ // in use at that point. The status line gives Claude Code's equivalent.
10
+
11
+ // Only the end of a long session file is read for the latest reading.
12
+ const TAIL_BYTES = 4 * 1024 * 1024;
13
+
14
+ // { used, size } from one rollout line, or null if it is not a token reading.
15
+ export function tokenReading(line) {
16
+ const payload = line?.type === 'event_msg' ? line.payload : null;
17
+ if (payload?.type !== 'token_count') return null;
18
+ const used = payload.info?.last_token_usage?.input_tokens;
19
+ if (!Number.isFinite(used) || used <= 0) return null;
20
+ const size = payload.info.model_context_window;
21
+ return { used, size: Number.isFinite(size) && size > 0 ? size : null };
22
+ }
23
+
24
+ // The session file opens with a session_meta line, which can be long: it holds
25
+ // the model's base instructions.
26
+ const META_BYTES = 1024 * 1024;
27
+
28
+ // The version of Codex that wrote a session file (its cli_version), or null.
29
+ export function readCodexVersion(file) {
30
+ if (typeof file !== 'string' || !file) return null;
31
+ let fd;
32
+ try {
33
+ fd = openSync(file, 'r');
34
+ const buffer = Buffer.alloc(Math.min(fstatSync(fd).size, META_BYTES));
35
+ readSync(fd, buffer, 0, buffer.length, 0);
36
+ const first = buffer.toString('utf8').split('\n', 1)[0];
37
+ const line = JSON.parse(first);
38
+ const version = line?.type === 'session_meta' ? line.payload?.cli_version : null;
39
+ return typeof version === 'string' ? version : null;
40
+ } catch {
41
+ return null;
42
+ } finally {
43
+ if (fd !== undefined) closeSync(fd);
44
+ }
45
+ }
46
+
47
+ // Whether a rollout line is a finished reply from the model.
48
+ function isReply(line) {
49
+ const payload = line?.payload;
50
+ if (line?.type === 'event_msg') return payload?.type === 'item_completed' && payload.item?.type === 'AgentMessage';
51
+ return line?.type === 'response_item' && payload?.type === 'message' && payload.role === 'assistant';
52
+ }
53
+
54
+ // The latest token reading and whether it came after the latest reply, or null.
55
+ function latestReading(file, tailBytes) {
56
+ if (typeof file !== 'string' || !file) return null;
57
+ let fd;
58
+ try {
59
+ fd = openSync(file, 'r');
60
+ const { size } = fstatSync(fd);
61
+ const length = Math.min(size, tailBytes);
62
+ const buffer = Buffer.alloc(length);
63
+ readSync(fd, buffer, 0, length, size - length);
64
+ const lines = buffer.toString('utf8').split('\n');
65
+ let afterReply = true;
66
+ for (let i = lines.length - 1; i >= 0; i--) {
67
+ const text = lines[i];
68
+ const maybeReading = text.includes('token_count');
69
+ if (!maybeReading && !(afterReply && (text.includes('AgentMessage') || text.includes('"assistant"')))) continue;
70
+ let line;
71
+ try {
72
+ line = JSON.parse(text);
73
+ } catch {
74
+ continue;
75
+ }
76
+ const reading = maybeReading ? tokenReading(line) : null;
77
+ if (reading) return { reading, afterReply };
78
+ if (isReply(line)) afterReply = false;
79
+ }
80
+ return null;
81
+ } catch {
82
+ return null;
83
+ } finally {
84
+ if (fd !== undefined) closeSync(fd);
85
+ }
86
+ }
87
+
88
+ // How much context a Codex session is using, from its latest token reading, or
89
+ // null when it has none yet (or the file cannot be read).
90
+ export function readCodexUsage(file, { tailBytes = TAIL_BYTES } = {}) {
91
+ return latestReading(file, tailBytes)?.reading ?? null;
92
+ }
93
+
94
+ // The reading for the reply that just ended, for the meter Codex shows after
95
+ // it. Codex writes that reply's token_count a few milliseconds before its Stop
96
+ // hook runs, close enough that the file may not have it yet. Until a reading
97
+ // follows the reply, it is read again, briefly; after that the older reading
98
+ // is used, which is only one request behind.
99
+ const SETTLE_MS = 250;
100
+ const RETRY_MS = 25;
101
+
102
+ const pause = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
103
+
104
+ export function readCodexReplyUsage(file, { tailBytes = TAIL_BYTES, settleMs = SETTLE_MS, wait = pause } = {}) {
105
+ let latest = latestReading(file, tailBytes);
106
+ for (let waited = 0; latest && !latest.afterReply && waited < settleMs; waited += RETRY_MS) {
107
+ wait(RETRY_MS);
108
+ latest = latestReading(file, tailBytes);
109
+ }
110
+ return latest?.reading ?? null;
111
+ }