context-carwash 0.1.4 → 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 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 leaves your machine. Two exceptions: the installer asks npm whether a newer version is out, and `carwash stats --share` sends anonymous totals if you run it and say yes.
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` again. 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.
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 in the background, and there is no telemetry. The installer reads the newest version number from npm (set `CARWASH_NO_UPDATE_CHECK=1` to skip that), and `stats --share` sends only when you run it and say yes.
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 and writes the error to `~/.carwash/errors.log`.
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.4",
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. Only two things go online: install, to ask npm
33
- for the newest version, and stats --share, once you have said yes.`;
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
- output = runHook({ input: await readAll(stdin), env, onError: (error) => logError(env, error) });
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
 
@@ -39,18 +39,18 @@ export function statsReport(sum, { inClaude = false } = {}) {
39
39
  // --- sharing ---------------------------------------------------------------
40
40
 
41
41
  // The Google Form shares go to. Each share becomes one row of the form's
42
- // response sheet. Until the form exists, sharing says it is not set up.
42
+ // response sheet.
43
43
  export const FORM = {
44
- action: null, // https://docs.google.com/forms/d/e/<form id>/formResponse
44
+ action: 'https://docs.google.com/forms/d/e/1FAIpQLScMU96dzQ1DC4p-K-09wRF6aTYC23Kw3VNmOZbnI55iyXQnYQ/formResponse',
45
45
  fields: {
46
- shareId: null,
47
- version: null,
48
- system: null,
49
- days: null,
50
- washes: null,
51
- prompts: null,
52
- notResent: null,
53
- briefCost: null,
46
+ shareId: 'entry.444662411',
47
+ version: 'entry.237740820',
48
+ system: 'entry.1585190609',
49
+ days: 'entry.1959206368',
50
+ washes: 'entry.822622585',
51
+ prompts: 'entry.93311455',
52
+ notResent: 'entry.1048114223',
53
+ briefCost: 'entry.164624594',
54
54
  },
55
55
  };
56
56
 
@@ -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
  }
@@ -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
 
@@ -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
- return briefAtStart(payload, ctx) ?? welcome(ctx);
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
- export function runHook({ input, env = process.env, platform = process.platform, now = Date.now(), me, running, onError } = {}) {
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':