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.
- package/CHANGELOG.md +12 -0
- package/README.md +27 -8
- package/package.json +3 -2
- package/src/cli.mjs +22 -11
- package/src/install/codex.mjs +256 -0
- package/src/install/doctor.mjs +52 -11
- package/src/install/install.mjs +91 -31
- package/src/paths.mjs +17 -2
- package/src/skill/codex/SKILL.md +47 -0
- package/src/skill/codex/agents/openai.yaml +3 -0
- package/src/stats/habits.mjs +107 -0
- package/src/stats/ledger.mjs +216 -17
- package/src/stats/report.mjs +32 -7
- package/src/tools.mjs +40 -0
- package/src/wash/brief.mjs +56 -17
- package/src/wash/hooks.mjs +141 -58
- package/src/wash/process.mjs +111 -0
- package/src/wash/rollout.mjs +111 -0
package/src/wash/hooks.mjs
CHANGED
|
@@ -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
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
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
|
|
18
|
+
// Carwash's three hooks, in Claude Code and in Codex:
|
|
15
19
|
//
|
|
16
|
-
// UserPromptExpansion /wash was typed.
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
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
|
|
27
|
-
//
|
|
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
|
|
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
|
|
35
|
-
// Code shows the reason under "operation blocked by hook",
|
|
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
|
-
|
|
58
|
-
|
|
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(
|
|
62
|
-
if (verb === 'help') return block(helpMessage(
|
|
63
|
-
return block(
|
|
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,
|
|
67
|
-
const
|
|
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
|
|
71
|
-
return { hookSpecificOutput: { hookEventName:
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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 ✓
|
|
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
|
|
192
|
+
if (!wash) return replyMeter(payload, ctx);
|
|
139
193
|
|
|
140
|
-
// Claude Code gives a prompt and every Stop of its turn the same id
|
|
141
|
-
//
|
|
142
|
-
//
|
|
143
|
-
|
|
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
|
|
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:
|
|
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
|
-
|
|
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(
|
|
208
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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:
|
|
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 '
|
|
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;
|
package/src/wash/process.mjs
CHANGED
|
@@ -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
|
+
}
|