mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-04 22:49:41 +02:00
Merge pull request #361 from timkjr/fix/statusline-injection-opt-out
fix(statusline): inject plan-usage telemetry via ephemeral CLI flag, never disk
This commit is contained in:
@@ -1062,13 +1062,6 @@ Object.assign(CodemanApp.prototype, {
|
||||
...(hasEnvOverrides ? { envOverrides } : {}),
|
||||
...(effort ? { effort } : {}),
|
||||
...(modelOverride !== undefined ? { modelOverride } : {}),
|
||||
// Plan-usage statusLine exporter (App Settings → Display). The server
|
||||
// ADDS our exporter on create when true; when false it intentionally
|
||||
// leaves any existing exporter in place (a per-repo settings.local.json
|
||||
// is shared by sibling sessions, so create-with-false must not yank it
|
||||
// — see the comment in session-routes create). Disabling the setting
|
||||
// removes it via the App Settings toggle path (system-routes), not here.
|
||||
statusLineTelemetry: this.planUsageChipEnabled(globalSettings),
|
||||
})
|
||||
}).then(r => r.json())
|
||||
);
|
||||
|
||||
@@ -2310,22 +2310,26 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
// Save to server (includes notification prefs for cross-browser persistence).
|
||||
// Strip device-specific DISPLAY keys so they never sync across devices —
|
||||
// localEcho/cjk/extendedKeyboard/skin are per-platform, and showPlanUsageLimits
|
||||
// is per-device too (desktop can show the usage chip while mobile stays hidden).
|
||||
// localEcho/cjk/extendedKeyboard/skin are per-platform.
|
||||
// webglRendererEnabled is per-device as well (renderer choice is GPU-specific,
|
||||
// and syncing would leak mobile's hidden-checkbox false onto desktop); it's
|
||||
// also absent from SettingsUpdateSchema, which is .strict() — sending it
|
||||
// would 400 the whole settings PUT.
|
||||
// Telemetry COLLECTION is requested out-of-band via statusLineTelemetry (sent on
|
||||
// ENABLE only, so a device with the chip OFF never strips the exporter that
|
||||
// another device's chip depends on — see system-routes settings handler).
|
||||
// showPlanUsageLimits is the ONE exception to "per-device keys never sync":
|
||||
// its DISPLAY stays per-device (loadAppSettingsFromServer only seeds it into
|
||||
// localStorage when a device has no value yet — same as every other display
|
||||
// key), but it ALSO doubles as the server-side plan-usage telemetry
|
||||
// COLLECTION switch (readPlanUsageTelemetryEnabled in hooks-config.ts, read
|
||||
// fresh at every claude session create/respawn), so unlike the others it
|
||||
// MUST flow through in `serverSettings` below on every save — including
|
||||
// OFF, which used to be un-sendable under the old one-way "ENABLE only"
|
||||
// action field this replaces.
|
||||
const {
|
||||
localEchoEnabled: _leo,
|
||||
cjkInputEnabled: _cjk,
|
||||
extendedKeyboardBar: _ekb,
|
||||
skin: _skin,
|
||||
language: _language,
|
||||
showPlanUsageLimits: _pul,
|
||||
showAttachmentsButton: _ahb,
|
||||
showFileViewerButton: _fvb,
|
||||
webglRendererEnabled: _wgl,
|
||||
@@ -2360,7 +2364,6 @@ Object.assign(CodemanApp.prototype, {
|
||||
try {
|
||||
const res = await this._apiPut('/api/settings', {
|
||||
...serverSettings,
|
||||
...(settings.showPlanUsageLimits ? { statusLineTelemetry: true } : {}),
|
||||
notificationPreferences: notifPrefsToSave,
|
||||
voiceSettings,
|
||||
});
|
||||
@@ -2624,10 +2627,11 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Resolved per-device state of the plan-usage chip. Desktop defaults ON,
|
||||
// handhelds default OFF (the mobile block in getDefaultSettings() sets false,
|
||||
// and the mobile-header-buttons-policy guard depends on that staying false).
|
||||
// Single source of truth for THREE call sites that must never disagree: the
|
||||
// App Settings checkbox, the chip's visibility, and the statusLineTelemetry
|
||||
// flag sent on session create. A chip shown without telemetry renders "—"
|
||||
// forever, which is exactly the drift this helper prevents.
|
||||
// Single source of truth for the two call sites that must never disagree:
|
||||
// the App Settings checkbox and the chip's visibility. Telemetry COLLECTION
|
||||
// no longer has a THIRD client-side call site here at all — the server reads
|
||||
// this same persisted setting directly (readPlanUsageTelemetryEnabled in
|
||||
// hooks-config.ts), fresh, at every claude session create/respawn.
|
||||
planUsageChipEnabled(settings = null) {
|
||||
const s = settings ?? this.loadAppSettingsFromStorage();
|
||||
return s.showPlanUsageLimits ?? this.getDefaultSettings().showPlanUsageLimits ?? true;
|
||||
@@ -3127,11 +3131,13 @@ Object.assign(CodemanApp.prototype, {
|
||||
'sessionLineageLines',
|
||||
]);
|
||||
// The plan-usage chip is a PER-DEVICE display setting (desktop default ON,
|
||||
// handheld default OFF): desktop can show it while mobile stays hidden. It
|
||||
// used to sync, so an older server.json may still carry a value — drop it
|
||||
// so the server value is NEVER
|
||||
// seeded into a device that didn't explicitly enable it (collection is handled
|
||||
// separately via the statusLineTelemetry action, not this display flag).
|
||||
// handheld default OFF): desktop can show it while mobile stays hidden. Drop
|
||||
// the server's stored value here so it is NEVER seeded into a device that
|
||||
// didn't explicitly enable it — even though this SAME setting also drives
|
||||
// server-side telemetry collection now (readPlanUsageTelemetryEnabled in
|
||||
// hooks-config.ts), that's a read the server does directly from settings.json
|
||||
// at spawn time; it has nothing to do with what gets merged into THIS
|
||||
// device's local display preference.
|
||||
delete appSettings.showPlanUsageLimits;
|
||||
// Merge settings: non-display keys always sync from server,
|
||||
// display keys only seed from server when localStorage has no value
|
||||
|
||||
@@ -94,7 +94,6 @@ import {
|
||||
writeHooksConfig,
|
||||
updateCaseModel,
|
||||
stripCaseEnvKeys,
|
||||
applyStatusLineConfig,
|
||||
applyAgentSkill,
|
||||
refreshUserAgentSkill,
|
||||
seedAgentSessionPreamble,
|
||||
@@ -949,27 +948,19 @@ export function registerSessionRoutes(
|
||||
await updateCaseModel(workingDir, body.modelOverride || null);
|
||||
}
|
||||
|
||||
// Plan-usage statusLine exporter (App Settings → Display → "Plan Usage
|
||||
// Limits"). Claude-only; runs for ANY working dir (linked cases / real repos,
|
||||
// where most sessions live), mirroring updateCaseModel above.
|
||||
//
|
||||
// ADD-ONLY: we never remove on create. Sessions in a repo share one
|
||||
// settings.local.json, so a single create-with-false (e.g. a client whose
|
||||
// synced setting hadn't loaded yet) must NOT yank the statusLine out from
|
||||
// under other live sessions in that repo — that breaks their footer + the
|
||||
// chip's data feed for everyone. The exporter is benign when the chip is off
|
||||
// (the footer just shows session status). isOurs-guarded so a user's own
|
||||
// statusLine is never touched.
|
||||
//
|
||||
// Same guard as the hooks call below (499d355): never for a remote attach
|
||||
// (workingDir is a user@host:session pseudo-path — the mkdir inside
|
||||
// applyStatusLineConfig would create it as a junk local dir), and only when
|
||||
// the caller named a workingDir — the process-cwd fallback is $HOME under
|
||||
// installer-created services, and a statusLine materializing in
|
||||
// ~/.claude/settings.local.json was never asked for.
|
||||
if (!remote && body.workingDir && (body.mode ?? 'claude') === 'claude' && body.statusLineTelemetry === true) {
|
||||
await applyStatusLineConfig(workingDir, true);
|
||||
}
|
||||
// Plan-usage telemetry (App Settings → header chip): no request-time field
|
||||
// here anymore, and NO disk write — a settings.local.json statusLine used
|
||||
// to take precedence over the user's own global/project statusLine for ANY
|
||||
// `claude` run in that directory, including entirely outside Codeman, with
|
||||
// no disclosure and no way to undo it (real bug, found 2026-08-31).
|
||||
// TmuxManager.createSession reads the persisted `showPlanUsageLimits`
|
||||
// setting FRESH at spawn (readPlanUsageTelemetryEnabled in hooks-config.ts)
|
||||
// and resolves it into an EPHEMERAL `claude --settings` CLI flag — never
|
||||
// written to disk, so a plain `claude` run outside Codeman is untouched —
|
||||
// and applies uniformly to every claude creation path (this route, cron,
|
||||
// the Ralph Loop API, quick-start), not just this one. That resolution
|
||||
// also self-heals: it strips any legacy disk-written exporter an older
|
||||
// Codeman build left behind.
|
||||
|
||||
// Hooks for the workspace this session runs in (install vs refresh-only is the
|
||||
// `workspaceHooksEnabled` setting; see applyWorkspaceHooks). Never for a remote
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
*/
|
||||
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { getCli } from '../../config/cli-registry/registry.js';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { existsSync, mkdirSync, readdirSync } from 'node:fs';
|
||||
@@ -33,7 +32,6 @@ import {
|
||||
import { subagentWatcher } from '../../subagent-watcher.js';
|
||||
import { imageWatcher } from '../../image-watcher.js';
|
||||
import { workflowRunWatcher } from '../../workflow-run-watcher.js';
|
||||
import { applyStatusLineConfig } from '../../hooks-config.js';
|
||||
import { getLifecycleLog } from '../../session-lifecycle-log.js';
|
||||
import {
|
||||
buildAwayDigest,
|
||||
@@ -938,7 +936,47 @@ export function registerSystemRoutes(
|
||||
// ========== Settings ==========
|
||||
|
||||
app.get('/api/settings', async () => {
|
||||
return readJsonConfig(SETTINGS_PATH, 'settings', {});
|
||||
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;
|
||||
});
|
||||
|
||||
app.put('/api/settings', async (req) => {
|
||||
@@ -992,9 +1030,9 @@ export function registerSystemRoutes(
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
// statusLineTelemetry and acknowledgeUnauthTunnel are ACTION fields (not stored
|
||||
// settings) — strip them before persisting so settings.json stays clean.
|
||||
const { statusLineTelemetry, acknowledgeUnauthTunnel, ...settingsToStore } = settings;
|
||||
// acknowledgeUnauthTunnel is an ACTION field (not a stored setting) — strip
|
||||
// it before persisting so settings.json stays clean.
|
||||
const { acknowledgeUnauthTunnel, ...settingsToStore } = settings;
|
||||
const merged = { ...existing, ...settingsToStore };
|
||||
await fs.writeFile(SETTINGS_PATH, JSON.stringify(merged, null, 2));
|
||||
|
||||
@@ -1007,7 +1045,7 @@ export function registerSystemRoutes(
|
||||
// Service toggles resolve from `merged` (existing + incoming), NEVER from the
|
||||
// raw request body. A PARTIAL PUT omits keys it does not intend to change, and
|
||||
// reading the body directly turned every omission into "apply the default":
|
||||
// a body of just `{statusLineTelemetry:true}` would START the subagent watcher
|
||||
// a body of just `{showPlanUsageLimits:true}` would START the subagent watcher
|
||||
// (`?? true`) and STOP the workflow + image watchers (`?? false`), silently
|
||||
// undoing the user's persisted config. Reading `merged` makes any PUT reconcile
|
||||
// services to the effective stored settings instead, which also self-heals
|
||||
@@ -1033,22 +1071,23 @@ export function registerSystemRoutes(
|
||||
}
|
||||
});
|
||||
|
||||
// Plan-usage chip: its DISPLAY is per-device (client-side, see settings-ui.js).
|
||||
// Telemetry COLLECTION is server-side and enable-sticky — when a client turns
|
||||
// the chip ON it sends statusLineTelemetry:true and we (re)inject our exporter
|
||||
// into every ACTIVE Claude session's working dir so the live % starts flowing
|
||||
// immediately (no new session needed). We deliberately never auto-REMOVE here:
|
||||
// the exporter is benign/print-through and a per-repo settings.local.json is
|
||||
// shared by sibling sessions, so one device's "off" must not yank the exporter
|
||||
// another device's chip depends on. Each dir handled once.
|
||||
if (statusLineTelemetry === true) {
|
||||
const dirs = new Set<string>();
|
||||
for (const session of ctx.sessions.values()) {
|
||||
if (getCli(session.mode)?.capabilities.statusLineTelemetry && session.workingDir)
|
||||
dirs.add(session.workingDir);
|
||||
}
|
||||
await Promise.all([...dirs].map((dir) => applyStatusLineConfig(dir, true).catch(() => {})));
|
||||
}
|
||||
// Plan-usage chip: its DISPLAY is per-device (client-side, see settings-ui.js),
|
||||
// but `showPlanUsageLimits` ALSO doubles as the telemetry COLLECTION switch,
|
||||
// persisted here in settingsToStore like any other setting (no special-casing
|
||||
// needed — see readPlanUsageTelemetryEnabled's doc comment in hooks-config.ts).
|
||||
// Telemetry COLLECTION used to be a SEPARATE, action-only, sticky mechanism
|
||||
// here: toggling the chip ON re-injected a statusLine.command into every
|
||||
// ACTIVE Claude session's settings.local.json so live % started flowing
|
||||
// without a new session. That disk write was the bug fixed 2026-08-31 (it
|
||||
// took precedence over the user's own statusline for ANY `claude` run in
|
||||
// that directory, including outside Codeman, with no way to undo it).
|
||||
// Collection is now decided by TmuxManager.createSession/respawnPane reading
|
||||
// `showPlanUsageLimits` FRESH from settings.json at spawn time — no
|
||||
// per-session field, no per-request threading through cron/Ralph-loop/
|
||||
// quick-start/interactive-create (they all reach the same read), and no
|
||||
// (re)injection into an already-running session needed here: the NEXT
|
||||
// respawn (a Ralph cycle, `/clear`, a PTY-exit restart) already picks up
|
||||
// whatever this PUT just persisted.
|
||||
|
||||
// Handle tunnel toggle dynamically
|
||||
if ('tunnelEnabled' in settings) {
|
||||
|
||||
+4
-8
@@ -524,8 +524,6 @@ export const CreateSessionSchema = z.object({
|
||||
effort: effortLevelSchema,
|
||||
/** Model override to write to .claude/settings.local.json (e.g., "opus[1m]"). Empty string clears. */
|
||||
modelOverride: z.string().max(50).optional(),
|
||||
/** Inject the Claude statusLine source for the shared plan-usage chip. Claude sessions only; Codex is host-polled. */
|
||||
statusLineTelemetry: z.boolean().optional(),
|
||||
openCodeConfig: OpenCodeConfigSchema,
|
||||
codexConfig: CodexConfigSchema,
|
||||
geminiConfig: GeminiConfigSchema,
|
||||
@@ -1298,13 +1296,11 @@ export const SettingsUpdateSchema = z
|
||||
showFileBrowser: z.boolean().optional(),
|
||||
showSubagents: z.boolean().optional(),
|
||||
showMultiMonitorButton: z.boolean().optional(),
|
||||
// Doubles as the plan-usage telemetry COLLECTION switch, read fresh from
|
||||
// disk by readPlanUsageTelemetryEnabled() (hooks-config.ts) at every claude
|
||||
// session create/respawn — not just the chip's DISPLAY preference. See that
|
||||
// function's doc comment for why one persisted field serves both.
|
||||
showPlanUsageLimits: z.boolean().optional(),
|
||||
// Action field (NOT persisted as a setting): when true, (re)injects the
|
||||
// plan-usage statusLine exporter into active Claude sessions so live usage %
|
||||
// starts flowing. Sent on ENABLE only — the chip's DISPLAY is per-device
|
||||
// (client-side), but telemetry COLLECTION is server-side, so the per-device
|
||||
// toggle signals it out-of-band here rather than via showPlanUsageLimits.
|
||||
statusLineTelemetry: z.boolean().optional(),
|
||||
showRedrawButton: z.boolean().optional(),
|
||||
// Input
|
||||
gestureControlEnabled: z.boolean().optional(),
|
||||
|
||||
+4
-1
@@ -1450,7 +1450,10 @@ export class WebServer extends EventEmitter {
|
||||
// PER-DEVICE by the client (settings-ui.js applyHeaderVisibilitySettings). It
|
||||
// used to be server-revealed from a synced setting, but that leaked the desktop
|
||||
// choice onto mobile — display is now per-device only (like the response viewer).
|
||||
// Telemetry collection stays server-side via the statusLineTelemetry action.
|
||||
// Telemetry collection stays server-side, reading `showPlanUsageLimits` fresh
|
||||
// from settings.json at every claude session create/respawn (see
|
||||
// readPlanUsageTelemetryEnabled in hooks-config.ts) — the same setting this
|
||||
// display-visibility check reads, doing double duty.
|
||||
// Detached single-session ("solo") window: inject the target session id so
|
||||
// the client can enter solo mode even if a (network-first) service worker
|
||||
// later serves a cached shell. The client primarily detects solo mode from
|
||||
|
||||
Reference in New Issue
Block a user