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.
- package/CHANGELOG.md +7 -0
- package/README.md +21 -6
- package/package.json +3 -2
- package/src/cli.mjs +20 -8
- 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 +13 -2
- package/src/skill/codex/SKILL.md +47 -0
- package/src/skill/codex/agents/openai.yaml +3 -0
- package/src/stats/ledger.mjs +70 -9
- package/src/stats/report.mjs +10 -3
- package/src/tools.mjs +40 -0
- package/src/wash/brief.mjs +56 -17
- package/src/wash/hooks.mjs +138 -55
- package/src/wash/process.mjs +111 -0
- package/src/wash/rollout.mjs +111 -0
package/src/wash/brief.mjs
CHANGED
|
@@ -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
|
|
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}. ${
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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'}:
|
|
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
|
|
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] : []),
|
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
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
|
|
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
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(
|
|
63
|
-
return block(
|
|
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;
|