fix(statusline): GET /api/settings never writes, and a save sends the collection switch only on a flip

Two follow-ups to #361's sticky telemetry switch.

GET /api/settings reconciled an absent showPlanUsageLimits by persisting
true, but readJsonConfig() answers {} for ANY read failure (a parse
error, EACCES, EMFILE, a read landing inside PUT's non-atomic write), not
only ENOENT, and every page load calls this route, so one unlucky read
replaced the whole settings file with a one-key file. The route is a
plain read again and the default moved into the reader:
readPlanUsageTelemetryEnabled() treats an absent key as ON, the same way
readWorkspaceHooksEnabled() does, which is what the desktop chip already
shows for an install that never touched the setting.

saveAppSettings() sent showPlanUsageLimits on every save. The chip
defaults OFF on handhelds, so a phone saving its font size persisted
false and switched collection off for every desktop, whose chip then
went stale with no error anywhere. The key is now stripped like the
other per-device display keys and re-added only when the save FLIPS the
chip relative to what the device had (planUsageCollectionFlip), so an
explicit toggle on any device still writes it in either direction.

Tests pin both: the GET route with a mocked filesystem (absent, missing,
EACCES, garbage, explicit), the reader default, and the flip helper plus
its wiring in saveAppSettings.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-09-14 15:59:00 +02:00
parent b2b2c767ea
commit 707ea345eb
11 changed files with 214 additions and 121 deletions
+12 -41
View File
@@ -936,47 +936,18 @@ export function registerSystemRoutes(
// ========== Settings ==========
app.get('/api/settings', async () => {
const settings = await readJsonConfig<Record<string, unknown>>(SETTINGS_PATH, 'settings', {});
// Plan-usage chip default reconciliation (PR #361 follow-up): the client's
// own default resolution (planUsageChipEnabled() in settings-ui.js) shows
// the header chip and the App Settings checkbox as already ON whenever this
// key has never been set — a discoverability default from 1.9.3, unrelated
// to consent. Meanwhile readPlanUsageTelemetryEnabled() (hooks-config.ts)
// deliberately treats an absent key as "no telemetry" (privacy: never POST
// usage data without an explicit persisted yes, pinned by its own unit
// tests). Nothing ever reconciled those two independent guesses, so a
// fresh install showed a checked box that silently did nothing until the
// user opened Settings and hit Save at least once — verified live: an
// install that had never touched this setting had NO showPlanUsageLimits
// key in settings.json, and its running Claude process's argv carried no
// --settings flag at all, i.e. zero telemetry ever collected.
//
// Resolve it ONCE, here, the first time anything reads settings: if the
// key is truly ABSENT (never explicit true or false), persist the same
// desktop-default-ON resolution the client already shows, so "chip visible"
// and "telemetry collected" become the same fact instead of two defaults
// that happen to disagree. readPlanUsageTelemetryEnabled()'s own
// absent-means-false contract is untouched — after this runs once the key
// is never absent again, so that branch stays correct in isolation (its
// unit tests keep passing unmodified) while being unreachable in practice
// for any install that has ever called this route. An explicit false the
// user sets afterward is respected forever; this only fires on true absence.
if (!('showPlanUsageLimits' in settings)) {
settings.showPlanUsageLimits = true;
try {
const dir = dirname(SETTINGS_PATH);
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}
await fs.writeFile(SETTINGS_PATH, JSON.stringify(settings, null, 2));
} catch {
// Best-effort: the resolved default still reaches this response even
// if the write fails, so the caller sees consistent data either way.
}
}
return settings;
// A plain read. This route must NEVER write settings.json: readJsonConfig()
// answers `{}` for ANY read failure (a parse error, EACCES, EMFILE, a read
// that lands inside PUT's non-atomic write), not only for a missing file,
// and every page load calls this route, so a "persist the default when the
// key is absent" reconcile here replaced a whole settings file with one key
// on the first unlucky read. The plan-usage default is resolved by the
// READERS instead: an absent `showPlanUsageLimits` means ON to
// readPlanUsageTelemetryEnabled() (hooks-config.ts), the same way an absent
// `workspaceHooksEnabled` means ON, and the client resolves its own display
// default through planUsageChipEnabled(). Pinned by
// test/routes/system-routes-settings-get-plan-usage-default.test.ts.
return readJsonConfig(SETTINGS_PATH, 'settings', {});
});
app.put('/api/settings', async (req) => {