context-carwash 0.1.5 → 0.1.6
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 +32 -0
- package/README.md +16 -4
- package/package.json +3 -2
- package/src/cli.mjs +26 -5
- package/src/install/doctor.mjs +136 -0
- package/src/install/notice.mjs +140 -0
- package/src/paths.mjs +8 -0
- package/src/statusline/run.mjs +19 -1
- package/src/wash/brief.mjs +2 -1
- package/src/wash/hooks.mjs +18 -3
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.6
|
|
4
|
+
|
|
5
|
+
- Update notices: once a day, a new session checks npm in the background. A line under the meter says when a newer version is out, or when the version you have carries a warning. `CARWASH_NO_UPDATE_CHECK=1` turns it off.
|
|
6
|
+
- `carwash doctor`: checks the install and prints versions and recent errors, ready to paste into a bug report.
|
|
7
|
+
- The status line says when a hook has failed, until `doctor` has shown the error.
|
|
8
|
+
- `/wash help` shows the version you have.
|
|
9
|
+
|
|
10
|
+
## 0.1.5
|
|
11
|
+
|
|
12
|
+
- `stats --share` sends to the Context Carwash Google Form.
|
|
13
|
+
|
|
14
|
+
## 0.1.4
|
|
15
|
+
|
|
16
|
+
- `carwash stats` and `/wash stats`: how much context `/wash` has kept from being sent again, measured from the fresh sessions' transcripts.
|
|
17
|
+
|
|
18
|
+
## 0.1.3
|
|
19
|
+
|
|
20
|
+
- An out-of-date installer hands over to the newest release.
|
|
21
|
+
|
|
22
|
+
## 0.1.2
|
|
23
|
+
|
|
24
|
+
- The installer says when a newer version is on npm.
|
|
25
|
+
|
|
26
|
+
## 0.1.1
|
|
27
|
+
|
|
28
|
+
- Shorter welcome note. Status line hints are sized to the window.
|
|
29
|
+
|
|
30
|
+
## 0.1.0
|
|
31
|
+
|
|
32
|
+
- First release on npm: the context meter, the installer and uninstaller, `/wash`, and brief delivery on `/clear`.
|
package/README.md
CHANGED
|
@@ -5,7 +5,7 @@ A small, local-first utility for Claude Code.
|
|
|
5
5
|
- **Meter:** a quiet estimate of the active context window in the status line.
|
|
6
6
|
- **Wash:** when you ask, Claude writes a short handoff brief, and your next `/clear` starts a fresh session that begins with it.
|
|
7
7
|
|
|
8
|
-
Nothing happens automatically, and nothing
|
|
8
|
+
Nothing happens automatically, and nothing about you is ever sent. Carwash goes online in two ways: once a day it reads its own public page on npm to see if a newer version is out (you can turn that off), and `carwash stats --share` sends anonymous totals if you run it and say yes.
|
|
9
9
|
|
|
10
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
11
|
|
|
@@ -18,7 +18,9 @@ npx context-carwash preview # sample meters in your terminal, nothing instal
|
|
|
18
18
|
npx context-carwash install # lists what it will change, then asks
|
|
19
19
|
```
|
|
20
20
|
|
|
21
|
-
To update later, run `npx context-carwash install
|
|
21
|
+
To update later, run `npx context-carwash@latest install`. Waiting briefs are kept. If npx reruns an older copy it downloaded before, the installer notices that npm has a newer version and installs that one instead.
|
|
22
|
+
|
|
23
|
+
You do not have to watch for new versions. Once a day, when a session starts, Carwash checks npm in the background. If a newer version is out, or the version you have has a known problem, a line under the meter says so, with the command to update. `/wash help` shows the version you have.
|
|
22
24
|
|
|
23
25
|
The installer copies itself to `~/.carwash/app` and makes three changes to Claude Code, all listed before you confirm:
|
|
24
26
|
|
|
@@ -106,11 +108,21 @@ npx context-carwash stats --share
|
|
|
106
108
|
|
|
107
109
|
This shows every field it would send, and sends only if you say yes. It sends totals to a Google Form: a random share id made for sharing only (so sharing again replaces your last share), the Carwash version, your operating system, days since your first brief, and the numbers above. No names, folders, projects, prompts or briefs. You are not signed in to send it.
|
|
108
110
|
|
|
111
|
+
## Something not working?
|
|
112
|
+
|
|
113
|
+
```sh
|
|
114
|
+
npx context-carwash doctor
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
This checks that the status line, hooks and `/wash` command are still in place, and shows your Carwash, Node, Claude Code and operating system versions, the newest version on npm, and the last few errors. Paste it into a [new issue](https://github.com/Spobi/context-carwash/issues/new).
|
|
118
|
+
|
|
119
|
+
If a hook fails, the status line shows `Carwash hit an error` until you run `doctor`.
|
|
120
|
+
|
|
109
121
|
## Where things are kept
|
|
110
122
|
|
|
111
|
-
Briefs are stored in `~/.carwash`, readable only by you, and never inside your project. So is the stats ledger (`~/.carwash/stats`): one small file per brief with its size, its level, and which fresh sessions it went to, but never the brief or your prompts. Nothing is uploaded
|
|
123
|
+
Briefs are stored in `~/.carwash`, readable only by you, and never inside your project. So is the stats ledger (`~/.carwash/stats`): one small file per brief with its size, its level, and which fresh sessions it went to, but never the brief or your prompts. Nothing about you is uploaded, and there is no telemetry. Once a day, a new session reads Carwash's public page on npm: the newest version, and any warning about the one you have. That is a download, like opening a web page, and it sends nothing about you, your projects or your use. The answer is kept in `~/.carwash/notice.json`. `install` and `doctor` read the same page when you run them. Set `CARWASH_NO_UPDATE_CHECK=1` to turn all of this off. `stats --share` sends only when you run it and say yes.
|
|
112
124
|
|
|
113
|
-
If a hook fails it stays silent
|
|
125
|
+
If a hook fails it stays silent, writes the error to `~/.carwash/errors.log`, and the status line says so.
|
|
114
126
|
|
|
115
127
|
## Uninstall
|
|
116
128
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "context-carwash",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.6",
|
|
4
4
|
"description": "A quiet context meter and deliberate fresh-session handoff for Claude Code.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -21,7 +21,8 @@
|
|
|
21
21
|
},
|
|
22
22
|
"files": [
|
|
23
23
|
"bin",
|
|
24
|
-
"src"
|
|
24
|
+
"src",
|
|
25
|
+
"CHANGELOG.md"
|
|
25
26
|
],
|
|
26
27
|
"scripts": {
|
|
27
28
|
"test": "node --test"
|
package/src/cli.mjs
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import { appendFileSync, existsSync, statSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { createInterface } from 'node:readline';
|
|
3
3
|
import { applyInstall, applyUninstall, describeInstall, describeUninstall, packageVersion, planInstall, planUninstall } from './install/install.mjs';
|
|
4
|
+
import { claudeVersion, doctorReport, markErrorsSeen } from './install/doctor.mjs';
|
|
5
|
+
import { fetchNotice, refreshNotice, startCheck } from './install/notice.mjs';
|
|
4
6
|
import { UserError } from './install/settings.mjs';
|
|
5
7
|
import { isNewer, latestVersion, runRelease } from './install/update.mjs';
|
|
6
|
-
import { errorLogPath, welcomedPath } from './paths.mjs';
|
|
8
|
+
import { errorLogPath, installRecordPath, welcomedPath } from './paths.mjs';
|
|
9
|
+
import { readJson } from './files.mjs';
|
|
7
10
|
import { refreshLedger, totals } from './stats/ledger.mjs';
|
|
8
11
|
import { FORM, describeShare, sendShare, shareId, sharePayload, shareReady, statsReport } from './stats/report.mjs';
|
|
9
12
|
import { render } from './statusline/render.mjs';
|
|
@@ -18,6 +21,7 @@ Usage: carwash <command>
|
|
|
18
21
|
uninstall [--yes] Undo everything install did
|
|
19
22
|
preview Print sample meters in this terminal
|
|
20
23
|
stats [--share] Show how much context /wash has saved; --share sends the totals anonymously, after asking
|
|
24
|
+
doctor Check the install and show recent errors, to paste into a bug report
|
|
21
25
|
--version Print the version
|
|
22
26
|
|
|
23
27
|
Inside Claude Code, once installed:
|
|
@@ -29,8 +33,10 @@ Inside Claude Code, once installed:
|
|
|
29
33
|
/wash stats The same totals as carwash stats
|
|
30
34
|
/wash help Explain the meter and the options
|
|
31
35
|
|
|
32
|
-
Nothing happens automatically
|
|
33
|
-
|
|
36
|
+
Nothing happens automatically, and nothing about you is ever sent. Once a day a
|
|
37
|
+
new session reads Carwash's public page on npm for the newest version and any
|
|
38
|
+
warning about yours (CARWASH_NO_UPDATE_CHECK=1 turns that off). install and
|
|
39
|
+
doctor read it too. stats --share sends totals only after you say yes.`;
|
|
34
40
|
|
|
35
41
|
async function readAll(stream) {
|
|
36
42
|
if (stream.isTTY) return '';
|
|
@@ -93,7 +99,7 @@ function logError(env, error) {
|
|
|
93
99
|
}
|
|
94
100
|
}
|
|
95
101
|
|
|
96
|
-
export async function main(argv, { env = process.env, stdin = process.stdin, stdout = process.stdout, stderr = process.stderr, latest = latestVersion, handOff = runRelease, form = FORM, share = sendShare } = {}) {
|
|
102
|
+
export async function main(argv, { env = process.env, stdin = process.stdin, stdout = process.stdout, stderr = process.stderr, latest = latestVersion, handOff = runRelease, form = FORM, share = sendShare, notice = fetchNotice, claude = claudeVersion, check = startCheck } = {}) {
|
|
97
103
|
const [command, ...rest] = argv;
|
|
98
104
|
const yes = rest.includes('--yes') || rest.includes('-y');
|
|
99
105
|
try {
|
|
@@ -104,7 +110,8 @@ export async function main(argv, { env = process.env, stdin = process.stdin, std
|
|
|
104
110
|
case 'hook': {
|
|
105
111
|
let output = null;
|
|
106
112
|
try {
|
|
107
|
-
|
|
113
|
+
const version = packageVersion();
|
|
114
|
+
output = runHook({ input: await readAll(stdin), env, version, onError: (error) => logError(env, error), checkUpdates: () => check(version, { env }) });
|
|
108
115
|
} catch (error) {
|
|
109
116
|
logError(env, error);
|
|
110
117
|
}
|
|
@@ -132,6 +139,8 @@ export async function main(argv, { env = process.env, stdin = process.stdin, std
|
|
|
132
139
|
return 1;
|
|
133
140
|
}
|
|
134
141
|
applyInstall(plan);
|
|
142
|
+
// Errors from before this install are not news.
|
|
143
|
+
markErrorsSeen(env);
|
|
135
144
|
const welcome = existsSync(welcomedPath(env)) ? '' : ' Its first message explains them.';
|
|
136
145
|
stdout.write(`Installed. Start a new Claude Code session to get the meter and /wash.${welcome}\n`);
|
|
137
146
|
return 0;
|
|
@@ -176,6 +185,18 @@ export async function main(argv, { env = process.env, stdin = process.stdin, std
|
|
|
176
185
|
case 'preview':
|
|
177
186
|
preview(env, stdout);
|
|
178
187
|
return 0;
|
|
188
|
+
case 'doctor': {
|
|
189
|
+
const running = packageVersion();
|
|
190
|
+
const installed = readJson(installRecordPath(env))?.version;
|
|
191
|
+
const newest = await notice(typeof installed === 'string' ? installed : running, { env });
|
|
192
|
+
stdout.write(doctorReport({ env, running, newest, claude: claude({ env }) }) + '\n');
|
|
193
|
+
markErrorsSeen(env);
|
|
194
|
+
return 0;
|
|
195
|
+
}
|
|
196
|
+
// Started in the background by a session's start, at most once a day.
|
|
197
|
+
case 'update-check':
|
|
198
|
+
await refreshNotice(packageVersion(), { env });
|
|
199
|
+
return 0;
|
|
179
200
|
case '--version':
|
|
180
201
|
case '-v':
|
|
181
202
|
case 'version':
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { arch, homedir, release } from 'node:os';
|
|
4
|
+
import { join, sep } from 'node:path';
|
|
5
|
+
import { readJson } from '../files.mjs';
|
|
6
|
+
import { errorLogPath, errorsSeenPath, installRecordPath, settingsPath, skillDir } from '../paths.mjs';
|
|
7
|
+
import { refreshLedger, totals } from '../stats/ledger.mjs';
|
|
8
|
+
import { readyRecords } from '../wash/store.mjs';
|
|
9
|
+
import { isCarwashCommand, isCarwashHook } from './install.mjs';
|
|
10
|
+
import { UPDATE_COMMAND } from './notice.mjs';
|
|
11
|
+
import { readSettings, resolveSettingsFile } from './settings.mjs';
|
|
12
|
+
import { isNewer } from './update.mjs';
|
|
13
|
+
|
|
14
|
+
// `carwash doctor`: everything needed to tell what is wrong, in one block
|
|
15
|
+
// that can be pasted into an issue. It reads, and changes only one thing:
|
|
16
|
+
// the status line stops mentioning errors once they have been shown here.
|
|
17
|
+
|
|
18
|
+
export const ISSUES_URL = 'https://github.com/Spobi/context-carwash/issues/new';
|
|
19
|
+
const HOOK_EVENTS = ['SessionStart', 'Stop', 'UserPromptExpansion'];
|
|
20
|
+
const RECENT_ERRORS = 3;
|
|
21
|
+
const ERROR_LINES = 8;
|
|
22
|
+
|
|
23
|
+
const tilde = (text) => text.split(homedir() + sep).join(`~${sep}`);
|
|
24
|
+
|
|
25
|
+
// Claude Code's own version, or null if `claude` is not on PATH.
|
|
26
|
+
export function claudeVersion({ env = process.env, platform = process.platform } = {}) {
|
|
27
|
+
try {
|
|
28
|
+
const result = spawnSync('claude', ['--version'], { env, encoding: 'utf8', timeout: 5000, shell: platform === 'win32', windowsHide: true });
|
|
29
|
+
const out = (result.stdout ?? '').trim();
|
|
30
|
+
return result.status === 0 && out ? out.split('\n')[0] : null;
|
|
31
|
+
} catch {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// The last few entries of the error log, newest last. Each entry starts with
|
|
37
|
+
// the time it was written; a stack trace follows on the next lines.
|
|
38
|
+
export function recentErrors(env = process.env) {
|
|
39
|
+
let text;
|
|
40
|
+
try {
|
|
41
|
+
text = readFileSync(errorLogPath(env), 'utf8');
|
|
42
|
+
} catch {
|
|
43
|
+
return [];
|
|
44
|
+
}
|
|
45
|
+
const entries = text.split(/^(?=\d{4}-\d\d-\d\dT)/m).map((entry) => entry.trimEnd()).filter(Boolean);
|
|
46
|
+
return entries.slice(-RECENT_ERRORS).map((entry) => {
|
|
47
|
+
const lines = tilde(entry).split('\n');
|
|
48
|
+
return lines.length > ERROR_LINES ? [...lines.slice(0, ERROR_LINES), ' …'].join('\n') : lines.join('\n');
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function settingsCheck(env) {
|
|
53
|
+
const file = resolveSettingsFile(settingsPath(env));
|
|
54
|
+
let data;
|
|
55
|
+
try {
|
|
56
|
+
data = readSettings(file).data;
|
|
57
|
+
} catch {
|
|
58
|
+
return { readable: false };
|
|
59
|
+
}
|
|
60
|
+
const groups = (event) => (Array.isArray(data?.hooks?.[event]) ? data.hooks[event] : []);
|
|
61
|
+
const hooks = Object.fromEntries(HOOK_EVENTS.map((event) => [event, groups(event).some((group) => Array.isArray(group?.hooks) && group.hooks.some((hook) => isCarwashHook(hook?.command)))]));
|
|
62
|
+
return { readable: true, statusLine: isCarwashCommand(data?.statusLine?.command), hooks };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const date = (iso) => {
|
|
66
|
+
const time = Date.parse(iso ?? '');
|
|
67
|
+
return Number.isFinite(time) ? new Date(time).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' }) : null;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
// `running` is this copy's version, which under npx may differ from the copy
|
|
71
|
+
// installed in ~/.carwash/app. `newest` is what npm said just now: { latest,
|
|
72
|
+
// warning } or null.
|
|
73
|
+
export function doctorReport({ env = process.env, now = Date.now(), running, newest, claude, platform = process.platform }) {
|
|
74
|
+
const row = (label, value) => ` ${label.padEnd(18)}${value}`;
|
|
75
|
+
const record = readJson(installRecordPath(env));
|
|
76
|
+
const installed = typeof record?.version === 'string' ? record.version : null;
|
|
77
|
+
const out = ['Context Carwash doctor', ''];
|
|
78
|
+
|
|
79
|
+
if (installed) {
|
|
80
|
+
const when = date(record.installedAt);
|
|
81
|
+
out.push(row('Installed', `${installed}${when ? ` (first installed ${when})` : ''}`));
|
|
82
|
+
} else {
|
|
83
|
+
out.push(row('Installed', 'no (run: npx context-carwash install)'));
|
|
84
|
+
}
|
|
85
|
+
if (running && running !== installed) out.push(row('This copy', running));
|
|
86
|
+
if (env.CARWASH_NO_UPDATE_CHECK) out.push(row('Newest on npm', 'not checked (CARWASH_NO_UPDATE_CHECK is set)'));
|
|
87
|
+
else if (!newest) out.push(row('Newest on npm', 'could not check (offline?)'));
|
|
88
|
+
else out.push(row('Newest on npm', `${newest.latest}${installed && isNewer(newest.latest, installed) ? ` · to update: ${UPDATE_COMMAND}` : ''}`));
|
|
89
|
+
if (newest?.warning) out.push(row('Warning', newest.warning));
|
|
90
|
+
|
|
91
|
+
out.push(row('Node', process.version));
|
|
92
|
+
out.push(row('System', `${platform} ${release()} ${arch()}`));
|
|
93
|
+
out.push(row('Claude Code', claude ?? 'not found on PATH'));
|
|
94
|
+
|
|
95
|
+
if (installed) {
|
|
96
|
+
const check = settingsCheck(env);
|
|
97
|
+
const yes = (ok) => (ok ? 'ok' : 'MISSING');
|
|
98
|
+
if (!check.readable) {
|
|
99
|
+
out.push(row('Settings', `could not read ${tilde(settingsPath(env))}`));
|
|
100
|
+
} else {
|
|
101
|
+
out.push(row('Status line', yes(check.statusLine)));
|
|
102
|
+
out.push(row('Hooks', HOOK_EVENTS.map((event) => `${event} ${yes(check.hooks[event])}`).join(' · ')));
|
|
103
|
+
}
|
|
104
|
+
out.push(row('/wash command', yes(existsSync(join(skillDir(env), 'SKILL.md')))));
|
|
105
|
+
let waiting = '?';
|
|
106
|
+
let written = '?';
|
|
107
|
+
try {
|
|
108
|
+
waiting = String(readyRecords(env, now).length);
|
|
109
|
+
} catch {
|
|
110
|
+
// unreadable store
|
|
111
|
+
}
|
|
112
|
+
try {
|
|
113
|
+
written = String(totals(refreshLedger(env, { now })).washes);
|
|
114
|
+
} catch {
|
|
115
|
+
// unreadable ledger
|
|
116
|
+
}
|
|
117
|
+
out.push(row('Briefs waiting', waiting));
|
|
118
|
+
out.push(row('Briefs written', written));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const errors = recentErrors(env);
|
|
122
|
+
out.push('');
|
|
123
|
+
if (!errors.length) out.push(`Errors: none logged.`);
|
|
124
|
+
else out.push(`Last ${errors.length === 1 ? 'error' : `${errors.length} errors`} (from ${tilde(errorLogPath(env))}):`, '', ...errors.map((entry) => entry.replace(/^/gm, ' ')));
|
|
125
|
+
out.push('', `Something wrong? Open an issue and paste everything above: ${ISSUES_URL}`);
|
|
126
|
+
return out.join('\n');
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// The status line has said "hit an error" until now; this was the look.
|
|
130
|
+
export function markErrorsSeen(env = process.env) {
|
|
131
|
+
try {
|
|
132
|
+
writeFileSync(errorsSeenPath(env), `${new Date().toISOString()}\n`, { mode: 0o600 });
|
|
133
|
+
} catch {
|
|
134
|
+
// nowhere to note it
|
|
135
|
+
}
|
|
136
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { statSync } from 'node:fs';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { readJson, writePrivateJson } from '../files.mjs';
|
|
5
|
+
import { errorLogPath, errorsSeenPath, noticePath } from '../paths.mjs';
|
|
6
|
+
import { isNewer } from './update.mjs';
|
|
7
|
+
|
|
8
|
+
// Whether this copy is out of date, and any warning the author has put on it.
|
|
9
|
+
//
|
|
10
|
+
// At most once a day, a new session starts a background read of Carwash's
|
|
11
|
+
// public page on npm: the newest version, and the `npm deprecate` message for
|
|
12
|
+
// each version, if any. Nothing about the user is sent. The answer is kept in
|
|
13
|
+
// ~/.carwash/notice.json, where the status line and session start read it
|
|
14
|
+
// without going online themselves. CARWASH_NO_UPDATE_CHECK turns all of it off.
|
|
15
|
+
|
|
16
|
+
const PACKAGE_URL = 'https://registry.npmjs.org/context-carwash';
|
|
17
|
+
const TIMEOUT_MS = 5000;
|
|
18
|
+
const CHECK_EVERY_MS = 24 * 60 * 60 * 1000;
|
|
19
|
+
const MAX_WARNING = 200;
|
|
20
|
+
|
|
21
|
+
export const UPDATE_COMMAND = 'npx context-carwash@latest install';
|
|
22
|
+
export const DOCTOR_COMMAND = 'npx context-carwash doctor';
|
|
23
|
+
|
|
24
|
+
const optedOut = (env) => Boolean(env.CARWASH_NO_UPDATE_CHECK);
|
|
25
|
+
|
|
26
|
+
// Author text goes on the user's screen, so no control characters (escape
|
|
27
|
+
// sequences could restyle or rewrite the terminal) and no essays.
|
|
28
|
+
function cleanWarning(text) {
|
|
29
|
+
if (typeof text !== 'string') return null;
|
|
30
|
+
const flat = text.replace(/[\u0000-\u001f\u007f-\u009f]+/g, ' ').replace(/\s+/g, ' ').trim();
|
|
31
|
+
if (!flat) return null;
|
|
32
|
+
return flat.length > MAX_WARNING ? `${flat.slice(0, MAX_WARNING - 1)}…` : flat;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// What npm says about `version`: { latest, warning }, or null when it cannot
|
|
36
|
+
// say in time. Offline or odd answers are no answer, never an error.
|
|
37
|
+
export async function fetchNotice(version, { env = process.env, timeoutMs = TIMEOUT_MS } = {}) {
|
|
38
|
+
if (optedOut(env)) return null;
|
|
39
|
+
try {
|
|
40
|
+
// The abbreviated document: dist-tags and per-version deprecation only.
|
|
41
|
+
const response = await fetch(PACKAGE_URL, { signal: AbortSignal.timeout(timeoutMs), headers: { accept: 'application/vnd.npm.install-v1+json' } });
|
|
42
|
+
if (!response.ok) return null;
|
|
43
|
+
const doc = await response.json();
|
|
44
|
+
const latest = doc?.['dist-tags']?.latest;
|
|
45
|
+
if (typeof latest !== 'string') return null;
|
|
46
|
+
return { latest, warning: cleanWarning(doc?.versions?.[version]?.deprecated) };
|
|
47
|
+
} catch {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export const readNotice = (env = process.env) => readJson(noticePath(env));
|
|
53
|
+
|
|
54
|
+
// The background half: ask npm and keep the answer. A failed check keeps the
|
|
55
|
+
// last good answer, so going offline does not hide a warning.
|
|
56
|
+
export async function refreshNotice(version, { env = process.env, now = Date.now(), fetchNotice: ask = fetchNotice } = {}) {
|
|
57
|
+
const answer = await ask(version, { env });
|
|
58
|
+
const previous = readNotice(env) ?? {};
|
|
59
|
+
if (!answer) return previous;
|
|
60
|
+
const next = { ...previous, checkedAt: new Date(now).toISOString(), version, latest: answer.latest, warning: answer.warning };
|
|
61
|
+
writePrivateJson(noticePath(env), next);
|
|
62
|
+
return next;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Called as a session starts. Starts a check if the last one is a day old,
|
|
66
|
+
// and returns at once: the session never waits for the network. The attempt is
|
|
67
|
+
// noted first, so sessions opened together, or a machine that is offline, try
|
|
68
|
+
// once a day and not on every start.
|
|
69
|
+
export function startCheck(version, { env = process.env, now = Date.now(), run = spawnCheck } = {}) {
|
|
70
|
+
if (optedOut(env)) return false;
|
|
71
|
+
const previous = readNotice(env) ?? {};
|
|
72
|
+
const last = Date.parse(previous.attemptedAt ?? '');
|
|
73
|
+
if (Number.isFinite(last) && now - last < CHECK_EVERY_MS && now >= last) return false;
|
|
74
|
+
writePrivateJson(noticePath(env), { ...previous, attemptedAt: new Date(now).toISOString() });
|
|
75
|
+
run(env);
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function spawnCheck(env) {
|
|
80
|
+
const script = fileURLToPath(new URL('../../bin/carwash.mjs', import.meta.url));
|
|
81
|
+
const child = spawn(process.execPath, [script, 'update-check'], { env, detached: true, stdio: 'ignore', windowsHide: true });
|
|
82
|
+
child.on('error', () => {});
|
|
83
|
+
child.unref();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// What to tell someone running `version`, from what the last check found:
|
|
87
|
+
// { kind: 'warning' | 'update', latest, warning }, or null.
|
|
88
|
+
export function noticeFor(notice, version, env = process.env) {
|
|
89
|
+
if (optedOut(env) || !notice) return null;
|
|
90
|
+
const newer = isNewer(notice.latest, version);
|
|
91
|
+
// A warning is about the version it was read for. After an update it no
|
|
92
|
+
// longer applies, even before the next check.
|
|
93
|
+
if (notice.warning && notice.version === version) return { kind: 'warning', latest: newer ? notice.latest : null, warning: notice.warning };
|
|
94
|
+
if (newer) return { kind: 'update', latest: notice.latest, warning: null };
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Hook errors since someone last looked at them with `carwash doctor`.
|
|
99
|
+
// Install counts as looking, so errors from before an update do not nag.
|
|
100
|
+
export function unseenErrors(env = process.env) {
|
|
101
|
+
try {
|
|
102
|
+
const logged = statSync(errorLogPath(env)).mtimeMs;
|
|
103
|
+
let seen = 0;
|
|
104
|
+
try {
|
|
105
|
+
seen = statSync(errorsSeenPath(env)).mtimeMs;
|
|
106
|
+
} catch {
|
|
107
|
+
// never looked
|
|
108
|
+
}
|
|
109
|
+
return logged > seen;
|
|
110
|
+
} catch {
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// One line for under the meter, or null: the author's warning first, then
|
|
116
|
+
// hook errors, then a plain "newer version".
|
|
117
|
+
export function statusNotice(version, env = process.env) {
|
|
118
|
+
const notice = noticeFor(readNotice(env), version, env);
|
|
119
|
+
if (notice?.kind === 'warning') return `⚠ Carwash: ${notice.warning} · to update: ${UPDATE_COMMAND}`;
|
|
120
|
+
if (unseenErrors(env)) return `Carwash hit an error · details: ${DOCTOR_COMMAND}`;
|
|
121
|
+
if (notice?.kind === 'update') return `Carwash ${notice.latest} is out (you have ${version}) · ${UPDATE_COMMAND}`;
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Said once as a session starts, when there is something to say.
|
|
126
|
+
export function startNotice(version, env = process.env) {
|
|
127
|
+
const notice = noticeFor(readNotice(env), version, env);
|
|
128
|
+
if (!notice) return null;
|
|
129
|
+
if (notice.kind === 'warning') return `Carwash ${version}: ${notice.warning} To update: ${UPDATE_COMMAND}`;
|
|
130
|
+
return `Carwash ${notice.latest} is out (you have ${version}). To update: ${UPDATE_COMMAND}`;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Which version this is, for /wash help, with what the last check found.
|
|
134
|
+
export function versionLine(version, env = process.env) {
|
|
135
|
+
const notice = noticeFor(readNotice(env), version, env);
|
|
136
|
+
let line = `Carwash ${version}.`;
|
|
137
|
+
if (notice?.kind === 'warning') line += ` ${notice.warning} To update: ${UPDATE_COMMAND}`;
|
|
138
|
+
else if (notice?.kind === 'update') line += ` ${notice.latest} is out. To update: ${UPDATE_COMMAND}`;
|
|
139
|
+
return `${line} Something not working? Run in a terminal: ${DOCTOR_COMMAND}`;
|
|
140
|
+
}
|
package/src/paths.mjs
CHANGED
|
@@ -35,6 +35,14 @@ export const shareIdPath = (env = process.env) => join(carwashHome(env), 'share-
|
|
|
35
35
|
|
|
36
36
|
export const errorLogPath = (env = process.env) => join(carwashHome(env), 'errors.log');
|
|
37
37
|
|
|
38
|
+
// Touched when someone has looked at the errors (doctor) or just installed, so
|
|
39
|
+
// the status line only mentions errors logged after that.
|
|
40
|
+
export const errorsSeenPath = (env = process.env) => join(carwashHome(env), 'errors-seen');
|
|
41
|
+
|
|
42
|
+
// What the last daily check found on npm: the newest version and any warning
|
|
43
|
+
// the author put on this one.
|
|
44
|
+
export const noticePath = (env = process.env) => join(carwashHome(env), 'notice.json');
|
|
45
|
+
|
|
38
46
|
// Present once the first-run welcome has been shown.
|
|
39
47
|
export const welcomedPath = (env = process.env) => join(carwashHome(env), 'welcomed');
|
|
40
48
|
|
package/src/statusline/run.mjs
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
2
|
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { packageVersion } from '../install/install.mjs';
|
|
4
|
+
import { statusNotice } from '../install/notice.mjs';
|
|
3
5
|
import { installRecordPath } from '../paths.mjs';
|
|
4
6
|
import { render } from './render.mjs';
|
|
5
7
|
import { washState } from './state.mjs';
|
|
@@ -102,6 +104,22 @@ function wrappedCommand(env) {
|
|
|
102
104
|
}
|
|
103
105
|
}
|
|
104
106
|
|
|
107
|
+
// A line under the meter when there is news: the author's warning about this
|
|
108
|
+
// version, hook errors since doctor was last run, or a newer version. Cut to
|
|
109
|
+
// the window, since a wrapped line would push the meter around.
|
|
110
|
+
export function withNotice(text, env = process.env, version = packageVersion()) {
|
|
111
|
+
let notice = null;
|
|
112
|
+
try {
|
|
113
|
+
notice = statusNotice(version, env);
|
|
114
|
+
} catch {
|
|
115
|
+
// no news is fine
|
|
116
|
+
}
|
|
117
|
+
if (!notice) return text;
|
|
118
|
+
const columns = availableColumns(env);
|
|
119
|
+
if (visibleWidth(notice) > columns) notice = `${[...notice].slice(0, Math.max(1, columns - 1)).join('')}…`;
|
|
120
|
+
return text ? `${text}\n${notice}` : notice;
|
|
121
|
+
}
|
|
122
|
+
|
|
105
123
|
// Entry point for `carwash statusline`. Must never fail loudly: a broken
|
|
106
124
|
// status line is worse than a missing meter, and the user's own status line
|
|
107
125
|
// has to keep working whatever happens to ours.
|
|
@@ -126,7 +144,7 @@ export async function statusline({ input, env = process.env, platform = process.
|
|
|
126
144
|
} catch {
|
|
127
145
|
// Without it the meter still renders, minus any word about a brief.
|
|
128
146
|
}
|
|
129
|
-
return compose(theirs, payload, { env, platform, state });
|
|
147
|
+
return withNotice(compose(theirs, payload, { env, platform, state }), env);
|
|
130
148
|
} catch {
|
|
131
149
|
return (theirs ?? '').replace(/\s+$/, '');
|
|
132
150
|
}
|
package/src/wash/brief.mjs
CHANGED
|
@@ -127,7 +127,7 @@ const LEVEL_NOTES = {
|
|
|
127
127
|
|
|
128
128
|
// /wash help. `used` is this session's reading, when known, so each level can
|
|
129
129
|
// show the most tokens its brief may take here.
|
|
130
|
-
export function helpMessage(used) {
|
|
130
|
+
export function helpMessage(used, footer = null) {
|
|
131
131
|
const row = (args, note) => ` /wash ${args.padEnd(16)}${note}`;
|
|
132
132
|
const level = (name) => {
|
|
133
133
|
const cap = capFor(name, used);
|
|
@@ -144,6 +144,7 @@ export function helpMessage(used) {
|
|
|
144
144
|
'',
|
|
145
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
146
|
'Writing a brief is an ordinary Claude reply, so it uses tokens. help, discard and load do not.',
|
|
147
|
+
...(footer ? ['', footer] : []),
|
|
147
148
|
].join('\n');
|
|
148
149
|
}
|
|
149
150
|
|
package/src/wash/hooks.mjs
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { existsSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { packageVersion } from '../install/install.mjs';
|
|
3
|
+
import { startNotice, versionLine } from '../install/notice.mjs';
|
|
2
4
|
import { DEFAULT_LEVEL, capFor, estimateTokens, isLevel } from '../levels.mjs';
|
|
3
5
|
import { installRecordPath, welcomedPath } from '../paths.mjs';
|
|
4
6
|
import { recordDelivery, recordWash, refreshLedger, totals } from '../stats/ledger.mjs';
|
|
@@ -18,6 +20,8 @@ import { AUTO_LOAD_MS, addRecord, claim, findRecords, purge, readyRecords, remov
|
|
|
18
20
|
// save it.
|
|
19
21
|
// SessionStart A session began. If a brief is meant for it, hand the
|
|
20
22
|
// brief over. The first one after install says hello.
|
|
23
|
+
// It also starts the daily update check, in the
|
|
24
|
+
// background, and passes on what the last one found.
|
|
21
25
|
//
|
|
22
26
|
// Each handler returns the JSON to print for Claude Code, or null to stay out
|
|
23
27
|
// of the way. None of them ever clears, restarts, or runs anything.
|
|
@@ -55,7 +59,7 @@ function onExpansion(payload, ctx) {
|
|
|
55
59
|
if (verb === 'discard') return block(discard(payload, ref, ctx));
|
|
56
60
|
if (verb === 'load') return block(load(payload, ref, ctx));
|
|
57
61
|
if (verb === 'stats') return block(statsReport(totals(refreshLedger(ctx.env, { now: ctx.now, force: true }), ctx.now), { inClaude: true }));
|
|
58
|
-
if (verb === 'help') return block(helpMessage(loadUsage(ctx.env, payload.session_id)?.used ?? null));
|
|
62
|
+
if (verb === 'help') return block(helpMessage(loadUsage(ctx.env, payload.session_id)?.used ?? null, versionLine(ctx.version, ctx.env)));
|
|
59
63
|
return block(`/wash does not know "${verb}". ${USAGE}`);
|
|
60
64
|
}
|
|
61
65
|
|
|
@@ -217,7 +221,13 @@ function onSessionStart(payload, ctx) {
|
|
|
217
221
|
if (payload.source !== 'startup' && payload.source !== 'clear') return null;
|
|
218
222
|
if (unattended(ctx.env)) return null;
|
|
219
223
|
// The welcome waits for a start that has nothing else to say.
|
|
220
|
-
|
|
224
|
+
const result = briefAtStart(payload, ctx) ?? welcome(ctx);
|
|
225
|
+
quietly(ctx, () => ctx.checkUpdates?.());
|
|
226
|
+
let notice = null;
|
|
227
|
+
quietly(ctx, () => (notice = startNotice(ctx.version, ctx.env)));
|
|
228
|
+
if (!notice) return result;
|
|
229
|
+
if (!result) return { systemMessage: notice };
|
|
230
|
+
return { ...result, systemMessage: result.systemMessage ? `${result.systemMessage}\n\n${notice}` : notice };
|
|
221
231
|
}
|
|
222
232
|
|
|
223
233
|
function briefAtStart(payload, ctx) {
|
|
@@ -265,7 +275,10 @@ function briefAtStart(payload, ctx) {
|
|
|
265
275
|
// --- entry point -----------------------------------------------------------
|
|
266
276
|
|
|
267
277
|
// Handle one hook call. `input` is the JSON Claude Code sends on stdin.
|
|
268
|
-
|
|
278
|
+
//
|
|
279
|
+
// `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() } = {}) {
|
|
269
282
|
let payload;
|
|
270
283
|
try {
|
|
271
284
|
payload = JSON.parse(input);
|
|
@@ -279,6 +292,8 @@ export function runHook({ input, env = process.env, platform = process.platform,
|
|
|
279
292
|
me: me ?? currentProcess(env, platform),
|
|
280
293
|
isRunning: running ?? ((owner) => isRunning(owner, platform)),
|
|
281
294
|
onError,
|
|
295
|
+
checkUpdates,
|
|
296
|
+
version,
|
|
282
297
|
};
|
|
283
298
|
switch (payload.hook_event_name) {
|
|
284
299
|
case 'UserPromptExpansion':
|