context-carwash 0.2.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,9 +1,13 @@
1
1
  import { CHARS_PER_TOKEN, capFor } from '../levels.mjs';
2
+ import { colorAt, paint } from '../statusline/color.mjs';
2
3
  import { formatTokens } from '../statusline/render.mjs';
4
+ import { toolInfo, toolOf } from '../tools.mjs';
3
5
 
4
6
  // The wording around a brief: what Claude is told when writing one, how its
5
7
  // reply is recognised, and what the user and the next session are shown. Also
6
8
  // the two places Carwash explains itself: the first-run welcome and /wash help.
9
+ // Wording that names the tool or its commands takes the tool's id (see
10
+ // tools.mjs); Claude Code is the default.
7
11
 
8
12
  // Every brief opens with this heading. It is how a reply is known to be a
9
13
  // brief, and where anything written before it is cut off.
@@ -70,14 +74,14 @@ export function retryNote(level, cap, tokens) {
70
74
  );
71
75
  }
72
76
 
73
- const NEXT_STEP = 'Type /clear to start fresh with it. This is not a normal clear: your brief carries over. Old session stays in /resume.';
77
+ const nextStep = (tool) => `${toolInfo(tool).afterClear} This is not a normal clear: your brief carries over. Old session stays in /resume.`;
74
78
 
75
79
  // Shown to the user once a brief has been saved.
76
- export function readyMessage(record) {
80
+ export function readyMessage(record, tool = toolOf(record)) {
77
81
  let size = sizeOf(record);
78
82
  if (record.overCap) size += `, which is over the ${formatTokens(record.cap)} cap even after asking for a shorter one`;
79
83
  else if (record.cap) size += ` of ${formatTokens(record.cap)} cap`;
80
- return `Brief ready ✓ ${size}. ${NEXT_STEP}`;
84
+ return `Brief ready ✓ ${size}. ${nextStep(tool)}`;
81
85
  }
82
86
 
83
87
  // One line on how the repository differs from when the brief was written, or
@@ -100,16 +104,46 @@ export function stalenessLine(source, current) {
100
104
 
101
105
  // Shown to the user when a fresh session starts with a brief. The old
102
106
  // session's size is the reading /wash was sized from, when there was one.
103
- export function loadedMessage(record, now, stale) {
107
+ export function loadedMessage(record, now, stale, tool = 'claude') {
104
108
  const before = Number.isFinite(record.used) && record.used > 0 ? `, down from ~${formatTokens(record.used)} in the old session` : '';
105
- return (
106
- `Loaded your Carwash brief (${sizeOf(record)}, ${ageOf(record, now)})${before}.${stale ? ` ${stale}` : ''} ` +
107
- "That size is the brief alone; the meter also counts Claude Code's own baseline."
108
- );
109
+ const loaded = `Loaded your Carwash brief (${sizeOf(record)}, ${ageOf(record, now)})${before}.${stale ? ` ${stale}` : ''}`;
110
+ if (!toolInfo(tool).meter && !toolInfo(tool).replyMeter) return loaded;
111
+ return `${loaded} That size is the brief alone; the meter also counts ${toolInfo(tool).name}'s own baseline.`;
112
+ }
113
+
114
+ const METER_CELLS = 15;
115
+ // From this share of the window on, the meter says so in words.
116
+ const FULL_AT = 75;
117
+
118
+ // The meter as one line, for a tool that shows it after each reply rather than
119
+ // in its status line: the tokens in use, a bar for how full the window is, and
120
+ // what to do about it. `size` may be unknown, and then there is no bar. The
121
+ // bar's cells and "getting full" take the status line's colours at `depth`
122
+ // ('none' for plain text).
123
+ export function replyMeterMessage({ used, size }, tool = 'codex', depth = 'none') {
124
+ const parts = [`session context ~${formatTokens(used)}`];
125
+ const pct = size ? Math.min(100, (used / size) * 100) : null;
126
+ if (pct !== null) {
127
+ const filled = Math.round((pct / 100) * METER_CELLS);
128
+ let cells = '';
129
+ for (let i = 0; i < filled; i++) cells += paint('█', colorAt(((i + 0.5) / METER_CELLS) * 100), depth);
130
+ parts.push(`[${cells}${'░'.repeat(METER_CELLS - filled)}]`);
131
+ }
132
+ const hint = `${toolInfo(tool).wash} to compress, then /clear`;
133
+ parts.push(pct !== null && pct >= FULL_AT ? `${paint('getting full', colorAt(pct), depth)} · ${hint}` : hint);
134
+ return parts.join(' ');
109
135
  }
110
136
 
111
137
  // Shown once, at the first session after install.
112
- export function welcomeMessage() {
138
+ export function welcomeMessage(tool = 'claude') {
139
+ if (tool === 'codex') {
140
+ return [
141
+ 'Context Carwash installed ✓ (shown once)',
142
+ ' • After each reply: tokens in use, and how full the context is',
143
+ ' • Getting full? $wash, then /clear and send your next message. You start fresh, right where you left off',
144
+ ' • Nothing runs on its own · $wash help for options',
145
+ ].join('\n');
146
+ }
113
147
  return [
114
148
  'Context Carwash installed ✓ (shown once)',
115
149
  ' • Status line: tokens in use, and how full the context is',
@@ -127,14 +161,18 @@ const LEVEL_NOTES = {
127
161
 
128
162
  // /wash help. `used` is this session's reading, when known, so each level can
129
163
  // show the most tokens its brief may take here.
130
- export function helpMessage(used, footer = null) {
131
- const row = (args, note) => ` /wash ${args.padEnd(16)}${note}`;
164
+ export function helpMessage(used, footer = null, tool = 'claude') {
165
+ const { wash, model, meter, replyMeter } = toolInfo(tool);
166
+ const row = (args, note) => ` ${wash} ${args.padEnd(16)}${note}`;
132
167
  const level = (name) => {
133
168
  const cap = capFor(name, used);
134
169
  return row(name, `${LEVEL_NOTES[name]}${cap ? `, up to ${formatTokens(cap)} tokens` : ''}`);
135
170
  };
171
+ const intro = meter
172
+ ? `${wash} compresses this conversation into a short brief. Your next /clear then starts a fresh session that begins with it, and the old session stays in /resume.`
173
+ : `${wash} compresses this conversation into a short brief. Your next /clear then starts a fresh session, and the brief loads with the first message you send there. The old session stays in /resume.`;
136
174
  return [
137
- '/wash compresses this conversation into a short brief. Your next /clear then starts a fresh session that begins with it, and the old session stays in /resume.',
175
+ intro,
138
176
  '',
139
177
  level('light'),
140
178
  level('mid'),
@@ -142,21 +180,22 @@ export function helpMessage(used, footer = null) {
142
180
  row('discard', 'drop the brief; /clear is a normal clear again'),
143
181
  row('load [id]', 'make a waiting brief the one /clear uses'),
144
182
  '',
145
- "The status line shows how many tokens this conversation is using, and the bar shows how close that is to the model's limit.",
146
- 'Writing a brief is an ordinary Claude reply, so it uses tokens. help, discard and load do not.',
183
+ ...(meter ? ["The status line shows how many tokens this conversation is using, and the bar shows how close that is to the model's limit."] : []),
184
+ ...(replyMeter ? ["The line after each reply shows how many tokens this conversation is using, and the bar shows how close that is to the model's limit. It is shown to you only and costs no tokens."] : []),
185
+ `Writing a brief is an ordinary ${model} reply, so it uses tokens. help, discard and load do not.`,
147
186
  ...(footer ? ['', footer] : []),
148
187
  ].join('\n');
149
188
  }
150
189
 
151
190
  // Shown to the user when briefs are waiting but none was loaded.
152
- export function pointerMessage(records, now, why) {
191
+ export function pointerMessage(records, now, why, tool = 'claude') {
153
192
  const one = records.length === 1;
154
193
  const listed = records.slice(0, 3).map((record) => describe(record, now));
155
194
  if (records.length > 3) listed.push(`and ${records.length - 3} more`);
156
195
  return (
157
196
  `Carwash: ${one ? 'a brief is' : `${records.length} briefs are`} waiting for this folder. ` +
158
197
  `${one ? 'It was not' : 'None was'} loaded because ${why}. ${listed.join(' · ')}. ` +
159
- `To use ${one ? 'it' : 'one'}: /wash load ${one ? records[0].id : '<id>'}, then /clear.`
198
+ `To use ${one ? 'it' : 'one'}: ${toolInfo(tool).wash} load ${one ? records[0].id : '<id>'}, then /clear.`
160
199
  );
161
200
  }
162
201
 
@@ -167,7 +206,7 @@ export function composeContext(record, now, stale) {
167
206
  return [
168
207
  '# Carwash handoff',
169
208
  '',
170
- `The user ran /wash in an earlier session and started this one to carry on from it. The brief below (${record.level}, written ${ageOf(record, now)}) is what that session handed over. The earlier conversation is not available to you.`,
209
+ `The user ran ${toolInfo(toolOf(record)).wash} in an earlier session and started this one to carry on from it. The brief below (${record.level}, written ${ageOf(record, now)}) is what that session handed over. The earlier conversation is not available to you.`,
171
210
  'If the brief is cut off below, or a note says the full text was saved to a file, read that file in full before you reply.',
172
211
  'Check the current state of a file before acting on what the brief says about it.',
173
212
  ...(stale ? [stale] : []),
@@ -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
7
  import { recordDelivery, recordWash, refreshLedger, totals } from '../stats/ledger.mjs';
8
+ import { colorDepth } from '../statusline/color.mjs';
7
9
  import { statsReport } from '../stats/report.mjs';
8
- import { budgetNote, composeContext, describe, helpMessage, loadedMessage, parseReply, pointerMessage, readyMessage, retryNote, stalenessLine, welcomeMessage } from './brief.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
96
  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}`);
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;