context-carwash 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +102 -2
- package/bin/carwash.mjs +4 -0
- package/package.json +30 -4
- package/src/cli.mjs +156 -0
- package/src/files.mjs +36 -0
- package/src/install/install.mjs +354 -0
- package/src/install/settings.mjs +50 -0
- package/src/levels.mjs +23 -0
- package/src/paths.mjs +36 -0
- package/src/skill/SKILL.md +47 -0
- package/src/statusline/color.mjs +51 -0
- package/src/statusline/render.mjs +151 -0
- package/src/statusline/run.mjs +133 -0
- package/src/statusline/state.mjs +21 -0
- package/src/statusline/terminal.mjs +40 -0
- package/src/wash/brief.mjs +178 -0
- package/src/wash/git.mjs +51 -0
- package/src/wash/hooks.mjs +275 -0
- package/src/wash/process.mjs +60 -0
- package/src/wash/session.mjs +58 -0
- package/src/wash/store.mjs +151 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Spobi
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,103 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Context Carwash
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A small, local-first utility for Claude Code.
|
|
4
|
+
|
|
5
|
+
- **Meter:** a quiet estimate of the active context window in the status line.
|
|
6
|
+
- **Wash:** when you ask, Claude writes a short handoff brief, and your next `/clear` starts a fresh session that begins with it.
|
|
7
|
+
|
|
8
|
+
Nothing happens automatically, and nothing leaves your machine.
|
|
9
|
+
|
|
10
|
+
**Status:** pre-release. The meter and the typed `/wash` command work today. Clickable links in the status line and the one-step restart are not built yet. See [plan-3.md](plan-3.md) for the design.
|
|
11
|
+
|
|
12
|
+
## Install
|
|
13
|
+
|
|
14
|
+
Requires Node 18 or later.
|
|
15
|
+
|
|
16
|
+
```sh
|
|
17
|
+
npx context-carwash preview # sample meters in your terminal, nothing installed
|
|
18
|
+
npx context-carwash install # lists what it will change, then asks
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
To update later, run `npx context-carwash@latest install` again. Waiting briefs are kept.
|
|
22
|
+
|
|
23
|
+
The installer copies itself to `~/.carwash/app` and makes three changes to Claude Code, all listed before you confirm:
|
|
24
|
+
|
|
25
|
+
- **Status line** in `~/.claude/settings.json`. If you already have one, yours keeps running and the meter is added after it.
|
|
26
|
+
- **Three hooks** in the same file, added after any hooks you already have. They are how `/wash` saves a brief and how a fresh session receives it.
|
|
27
|
+
- **The `/wash` command** at `~/.claude/skills/wash`.
|
|
28
|
+
|
|
29
|
+
Start a new Claude Code session afterwards. The first one explains the meter and `/wash` in a short note, shown once.
|
|
30
|
+
|
|
31
|
+
## The meter
|
|
32
|
+
|
|
33
|
+
```text
|
|
34
|
+
session context ~136k [██████████░░░░░] /wash to compress & save context, then /clear keeps compressed context
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
The number is how many tokens the conversation is using. It comes from Claude Code and is an estimate. The bar shows how close that is to the most the model's context window holds, and changes colour as it fills. From three quarters on, the meter also says so in words:
|
|
38
|
+
|
|
39
|
+
```text
|
|
40
|
+
session context ~150k [███████████░░░░] getting full · /wash to compress & save context, then /clear keeps compressed context
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Narrower windows show less. The sentence shortens first: `/wash to compress & save context, then /clear` fits about half a Mac screen, `/wash to compress, then /clear` fits an 80-column window, and the narrowest windows show `/wash to compress`. Then the bar goes.
|
|
44
|
+
|
|
45
|
+
A full meter is information. It never triggers anything.
|
|
46
|
+
|
|
47
|
+
## Starting fresh with a brief
|
|
48
|
+
|
|
49
|
+
1. Type `/wash` (or `/wash light`, `/wash detailed`). Claude replies with a brief: what you are doing, where things stand, and what to do next. The status line changes to `Brief ready ✓`.
|
|
50
|
+
2. Type `/clear`. The new session starts with that brief already loaded, and says so: `Loaded your Carwash brief (mid ~1.1k, 2m ago), down from ~136k in the old session.`
|
|
51
|
+
|
|
52
|
+
Your old conversation is not deleted. It stays in `/resume`.
|
|
53
|
+
|
|
54
|
+
| Command | What it does |
|
|
55
|
+
|---|---|
|
|
56
|
+
| `/wash` | Write a `mid` brief |
|
|
57
|
+
| `/wash light` | Goal, current state, next step |
|
|
58
|
+
| `/wash mid` | Adds constraints, decisions, state detail, pitfalls |
|
|
59
|
+
| `/wash detailed` | Adds rationale, history, test results, risks, a step checklist |
|
|
60
|
+
| `/wash discard` | Drop the brief. `/clear` is a normal clear again |
|
|
61
|
+
| `/wash load [id]` | Make a waiting brief the one your next `/clear` uses |
|
|
62
|
+
| `/wash help` | Explain the meter and these options, with this session's size limits |
|
|
63
|
+
|
|
64
|
+
Each level has a size limit: the most tokens its brief may take, worked out from what the conversation is using (a hundredth for `light`, three hundredths for `mid`, a tenth for `detailed`). `/wash help` shows the limits for the session you are in.
|
|
65
|
+
|
|
66
|
+
Words after the level are passed to Claude as a note on what to emphasise: `/wash mid focus on the failing test`.
|
|
67
|
+
|
|
68
|
+
Things worth knowing:
|
|
69
|
+
|
|
70
|
+
- **A `/clear` with no brief ready is a normal clear.** Carwash only acts when you have asked for a brief in that terminal.
|
|
71
|
+
- **Writing a brief costs tokens.** It is an ordinary Claude reply. `help`, `discard` and `load` cost nothing: they never reach Claude.
|
|
72
|
+
- **The cap is a ceiling, not a target.** A brief is as short as it can be. If one comes out over its cap, Claude is asked to shorten it, up to twice. If it is still over, it is kept and the message says so.
|
|
73
|
+
- **Sizes are estimates.** The brief's size is worked out from its length. It covers the brief alone, so the meter in the new session reads higher: it also counts Claude Code's own baseline.
|
|
74
|
+
- **Closed the terminal before clearing?** Start `claude` in the same folder. If exactly one brief is waiting for that folder, it is under 12 hours old, and the session that wrote it has ended, it is loaded. If there is any doubt about which brief is meant, nothing is loaded and you are shown what is waiting, with the `/wash load <id>` to use.
|
|
75
|
+
- **Briefs expire after 48 hours.** A delivered brief is kept for 24 hours in case you want it again (`/wash load <id>`), then deleted.
|
|
76
|
+
|
|
77
|
+
## Where things are kept
|
|
78
|
+
|
|
79
|
+
Briefs are stored in `~/.carwash`, readable only by you, and never inside your project. Nothing is uploaded, and there is no telemetry.
|
|
80
|
+
|
|
81
|
+
If a hook fails it stays silent and writes the error to `~/.carwash/errors.log`.
|
|
82
|
+
|
|
83
|
+
## Uninstall
|
|
84
|
+
|
|
85
|
+
```sh
|
|
86
|
+
npx context-carwash uninstall
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
This puts `settings.json` back exactly as it was. If you have edited the file since installing, it removes only Carwash's own status line and hooks and keeps your edits. It then removes the `/wash` command and deletes `~/.carwash`, including any briefs still waiting.
|
|
90
|
+
|
|
91
|
+
## Limitations
|
|
92
|
+
|
|
93
|
+
- Terminal.app cannot click links. When clickable links arrive for other terminals, Terminal.app will keep showing the command to type.
|
|
94
|
+
- The status line re-runs every 5 seconds while a session is open, so it stays correct after `/wash discard` and `/wash load`. Each run reads a few small local files. If your own status line sets a refresh interval, yours is kept.
|
|
95
|
+
- Claude Code shows `help`, `discard`, `load`, and mistyped arguments under the heading "operation blocked by hook". That is Carwash answering the command itself instead of sending it to Claude.
|
|
96
|
+
|
|
97
|
+
## Platforms
|
|
98
|
+
|
|
99
|
+
Developed and tested on macOS. Automated tests also run on Linux and Windows; neither has been tried by hand yet.
|
|
100
|
+
|
|
101
|
+
## License
|
|
102
|
+
|
|
103
|
+
MIT
|
package/bin/carwash.mjs
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,32 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "context-carwash",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "A quiet context meter and deliberate fresh-session handoff for Claude Code.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/Spobi/context-carwash.git"
|
|
9
|
+
},
|
|
10
|
+
"homepage": "https://github.com/Spobi/context-carwash#readme",
|
|
11
|
+
"bugs": "https://github.com/Spobi/context-carwash/issues",
|
|
12
|
+
"keywords": [
|
|
13
|
+
"claude",
|
|
14
|
+
"claude-code",
|
|
15
|
+
"context",
|
|
16
|
+
"statusline"
|
|
17
|
+
],
|
|
18
|
+
"type": "module",
|
|
19
|
+
"bin": {
|
|
20
|
+
"carwash": "bin/carwash.mjs"
|
|
21
|
+
},
|
|
22
|
+
"files": [
|
|
23
|
+
"bin",
|
|
24
|
+
"src"
|
|
25
|
+
],
|
|
26
|
+
"scripts": {
|
|
27
|
+
"test": "node --test"
|
|
28
|
+
},
|
|
29
|
+
"engines": {
|
|
30
|
+
"node": ">=18"
|
|
31
|
+
}
|
|
32
|
+
}
|
package/src/cli.mjs
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { appendFileSync, existsSync, statSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { createInterface } from 'node:readline';
|
|
3
|
+
import { applyInstall, applyUninstall, describeInstall, describeUninstall, packageVersion, planInstall, planUninstall } from './install/install.mjs';
|
|
4
|
+
import { UserError } from './install/settings.mjs';
|
|
5
|
+
import { errorLogPath, welcomedPath } from './paths.mjs';
|
|
6
|
+
import { render } from './statusline/render.mjs';
|
|
7
|
+
import { statusline } from './statusline/run.mjs';
|
|
8
|
+
import { runHook } from './wash/hooks.mjs';
|
|
9
|
+
|
|
10
|
+
const HELP = `Context Carwash: a quiet context meter and a deliberate fresh start for Claude Code.
|
|
11
|
+
|
|
12
|
+
Usage: carwash <command>
|
|
13
|
+
|
|
14
|
+
install [--yes] Add the context meter and the /wash command to Claude Code
|
|
15
|
+
uninstall [--yes] Undo everything install did
|
|
16
|
+
preview Print sample meters in this terminal
|
|
17
|
+
--version Print the version
|
|
18
|
+
|
|
19
|
+
Inside Claude Code, once installed:
|
|
20
|
+
|
|
21
|
+
/wash [light | mid | detailed] Write a handoff brief (mid if you do not say)
|
|
22
|
+
/clear Start fresh with the brief that is ready
|
|
23
|
+
/wash discard Drop the brief; /clear is a normal clear again
|
|
24
|
+
/wash load [id] Make a waiting brief the one /clear will use
|
|
25
|
+
/wash help Explain the meter and the options
|
|
26
|
+
|
|
27
|
+
Nothing happens automatically, and nothing leaves this machine.`;
|
|
28
|
+
|
|
29
|
+
async function readAll(stream) {
|
|
30
|
+
if (stream.isTTY) return '';
|
|
31
|
+
let text = '';
|
|
32
|
+
stream.setEncoding('utf8');
|
|
33
|
+
for await (const chunk of stream) text += chunk;
|
|
34
|
+
return text;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function confirm(question, { yes, stdin, stdout }) {
|
|
38
|
+
if (yes) return true;
|
|
39
|
+
if (!stdin.isTTY) throw new UserError('Not running in a terminal, so there is nobody to ask. Re-run with --yes to confirm.');
|
|
40
|
+
const rl = createInterface({ input: stdin, output: stdout });
|
|
41
|
+
const answer = await new Promise((resolve) => rl.question(`${question} [y/N] `, resolve));
|
|
42
|
+
rl.close();
|
|
43
|
+
return /^y(es)?$/i.test(answer.trim());
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function preview(env, stdout) {
|
|
47
|
+
const sample = (pct, size = 200_000) => ({
|
|
48
|
+
context_window: {
|
|
49
|
+
context_window_size: size,
|
|
50
|
+
used_percentage: pct,
|
|
51
|
+
current_usage: { input_tokens: 10, cache_creation_input_tokens: 0, cache_read_input_tokens: Math.round((pct / 100) * size) - 10 },
|
|
52
|
+
},
|
|
53
|
+
});
|
|
54
|
+
const columns = Math.max(20, (stdout.columns ?? 120) - 4);
|
|
55
|
+
const out = [];
|
|
56
|
+
for (const [style, label] of [['ramp', 'Each block keeps its own colour'], ['solid', 'Whole bar takes the colour of the current reading']]) {
|
|
57
|
+
out.push(`${label}:`);
|
|
58
|
+
for (const pct of [3, 25, 45, 60, 68, 75, 90, 99]) out.push(` ${render(sample(pct), { env, columns: Infinity, barStyle: style }).text}`);
|
|
59
|
+
out.push('');
|
|
60
|
+
}
|
|
61
|
+
out.push('Narrower windows drop detail in this order:');
|
|
62
|
+
for (const width of [120, 100, 80, 60, 30, 10]) out.push(` ${String(width).padStart(3)} cols ${render(sample(68), { env, columns: width - 4 }).text}`);
|
|
63
|
+
out.push('', `This window (${columns + 4} columns), before any reply has arrived, then with 136k in use:`);
|
|
64
|
+
out.push(` ${render({}, { env, columns }).text}`, ` ${render(sample(68), { env, columns }).text}`);
|
|
65
|
+
out.push('', 'While /wash is writing a brief, and once one is ready for /clear:');
|
|
66
|
+
out.push(` ${render(sample(68), { env, columns, state: { washing: 'mid' } }).text}`);
|
|
67
|
+
out.push(` ${render(sample(68), { env, columns, state: { ready: { level: 'mid', tokens: 3100 } } }).text}`);
|
|
68
|
+
stdout.write(out.join('\n') + '\n');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// A hook must never fail loudly inside someone's session, so its errors go to
|
|
72
|
+
// a small private log instead of the screen.
|
|
73
|
+
function logError(env, error) {
|
|
74
|
+
try {
|
|
75
|
+
const file = errorLogPath(env);
|
|
76
|
+
const line = `${new Date().toISOString()} ${error?.stack ?? error}\n`;
|
|
77
|
+
let size = 0;
|
|
78
|
+
try {
|
|
79
|
+
size = statSync(file).size;
|
|
80
|
+
} catch {
|
|
81
|
+
// no log yet
|
|
82
|
+
}
|
|
83
|
+
if (size > 200_000) writeFileSync(file, line, { mode: 0o600 });
|
|
84
|
+
else appendFileSync(file, line, { mode: 0o600 });
|
|
85
|
+
} catch {
|
|
86
|
+
// nowhere to write it
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export async function main(argv, { env = process.env, stdin = process.stdin, stdout = process.stdout, stderr = process.stderr } = {}) {
|
|
91
|
+
const [command, ...rest] = argv;
|
|
92
|
+
const yes = rest.includes('--yes') || rest.includes('-y');
|
|
93
|
+
try {
|
|
94
|
+
switch (command) {
|
|
95
|
+
case 'statusline':
|
|
96
|
+
stdout.write((await statusline({ input: await readAll(stdin), env })) + '\n');
|
|
97
|
+
return 0;
|
|
98
|
+
case 'hook': {
|
|
99
|
+
let output = null;
|
|
100
|
+
try {
|
|
101
|
+
output = runHook({ input: await readAll(stdin), env });
|
|
102
|
+
} catch (error) {
|
|
103
|
+
logError(env, error);
|
|
104
|
+
}
|
|
105
|
+
if (output) stdout.write(JSON.stringify(output) + '\n');
|
|
106
|
+
return 0;
|
|
107
|
+
}
|
|
108
|
+
case 'install': {
|
|
109
|
+
const plan = planInstall({ env });
|
|
110
|
+
stdout.write(describeInstall(plan) + '\n\n');
|
|
111
|
+
if (!(await confirm('Proceed?', { yes, stdin, stdout }))) {
|
|
112
|
+
stdout.write('Nothing was changed.\n');
|
|
113
|
+
return 1;
|
|
114
|
+
}
|
|
115
|
+
applyInstall(plan);
|
|
116
|
+
const welcome = existsSync(welcomedPath(env)) ? '' : ' Its first message explains them.';
|
|
117
|
+
stdout.write(`Installed. Start a new Claude Code session to get the meter and /wash.${welcome}\n`);
|
|
118
|
+
return 0;
|
|
119
|
+
}
|
|
120
|
+
case 'uninstall': {
|
|
121
|
+
const plan = planUninstall({ env });
|
|
122
|
+
stdout.write(describeUninstall(plan) + '\n\n');
|
|
123
|
+
if (!(await confirm('Proceed?', { yes, stdin, stdout }))) {
|
|
124
|
+
stdout.write('Nothing was changed.\n');
|
|
125
|
+
return 1;
|
|
126
|
+
}
|
|
127
|
+
applyUninstall(plan);
|
|
128
|
+
stdout.write('Uninstalled.\n');
|
|
129
|
+
return 0;
|
|
130
|
+
}
|
|
131
|
+
case 'preview':
|
|
132
|
+
preview(env, stdout);
|
|
133
|
+
return 0;
|
|
134
|
+
case '--version':
|
|
135
|
+
case '-v':
|
|
136
|
+
case 'version':
|
|
137
|
+
stdout.write(packageVersion() + '\n');
|
|
138
|
+
return 0;
|
|
139
|
+
case undefined:
|
|
140
|
+
case 'help':
|
|
141
|
+
case '--help':
|
|
142
|
+
case '-h':
|
|
143
|
+
stdout.write(HELP + '\n');
|
|
144
|
+
return 0;
|
|
145
|
+
default:
|
|
146
|
+
stderr.write(`Unknown command: ${command}\n\n${HELP}\n`);
|
|
147
|
+
return 2;
|
|
148
|
+
}
|
|
149
|
+
} catch (error) {
|
|
150
|
+
if (error instanceof UserError) {
|
|
151
|
+
stderr.write(`carwash: ${error.message}\n`);
|
|
152
|
+
return 1;
|
|
153
|
+
}
|
|
154
|
+
throw error;
|
|
155
|
+
}
|
|
156
|
+
}
|
package/src/files.mjs
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
|
|
5
|
+
export const TEMP_SUFFIX = '.carwash-tmp';
|
|
6
|
+
|
|
7
|
+
// A directory only this user can enter.
|
|
8
|
+
export function privateDir(path) {
|
|
9
|
+
mkdirSync(path, { recursive: true, mode: 0o700 });
|
|
10
|
+
chmodSync(path, 0o700);
|
|
11
|
+
return path;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// Replace a file's contents in one step, keeping its permissions.
|
|
15
|
+
export function writeFileAtomic(file, bytes, mode) {
|
|
16
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
17
|
+
const keepMode = mode ?? (existsSync(file) ? statSync(file).mode & 0o777 : 0o600);
|
|
18
|
+
const temp = join(dirname(file), `.${process.pid}.${randomBytes(4).toString('hex')}${TEMP_SUFFIX}`);
|
|
19
|
+
writeFileSync(temp, bytes, { mode: keepMode });
|
|
20
|
+
renameSync(temp, file);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Parsed contents of a JSON file, or null if it is missing or unreadable.
|
|
24
|
+
export function readJson(file) {
|
|
25
|
+
try {
|
|
26
|
+
return JSON.parse(readFileSync(file, 'utf8'));
|
|
27
|
+
} catch {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Write JSON into Carwash's own storage, readable by this user only.
|
|
33
|
+
export function writePrivateJson(file, data) {
|
|
34
|
+
privateDir(dirname(file));
|
|
35
|
+
writeFileAtomic(file, JSON.stringify(data, null, 2) + '\n', 0o600);
|
|
36
|
+
}
|