mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-06 15:39:41 +02:00
fix(statusline): sticky telemetry collection, footer print-through, EOF fix
Responds to Ark0N's review round on the ephemeral-CLI-flag statusline injection rework: - Rebase-detail fixes: registry-gated telemetry eligibility via getCli(mode)?.capabilities.statusLineTelemetry instead of a hardcoded mode === 'claude' check, using the capability flag master's CLI-registry refactor already declares for exactly this purpose. - Design question settled: sticky (a). Rather than persisting the toggle as a new field and threading it through every session-creation path (cron, Ralph Loop API, quick-start), eliminated the per-session field entirely. readPlanUsageTelemetryEnabled() (hooks-config.ts) reads the existing showPlanUsageLimits setting fresh from settings.json at every claude create/respawn (TmuxManager.createSession/respawnPane) - no per-session state to survive a restart, and it applies uniformly to every creation path for free, since they all flow through the same TmuxManager methods. This required fixing a real bug found along the way: showPlanUsageLimits was not actually round-tripping through settings.json on save - settings-ui.js explicitly excluded it from the PUT body as a pure per-device display key. It now flows through normally (both true and false); the load-side per-device merge behavior is unchanged. Removed entirely as a result: the statusLineTelemetry field from CreateSessionSchema/SettingsUpdateSchema, CreateSessionOptions/ RespawnPaneOptions, Session._statusLineTelemetry (this is what makes the restart-persistence bug moot rather than patched), and the frontend send sites. - Footer print-through restored: the no-user-statusline branch of the exporter script now runs the telemetry POST in the foreground so its own stdout becomes the in-terminal footer, falling back to a plain "codeman" marker only on curl failure. - Background-subshell EOF fix: the wrap-a-real-statusline branch closes stdin too, not just stdout/stderr (`>/dev/null 2>&1 </dev/null &`) - the un-redirected subshell process itself, not curl, was what held a reader-to-EOF's pipe open for however long curl took to finish. Added curl --max-time 5 so a hung (not just refused) Codeman cannot wedge the render. Tests: real-shell-execution tests for the footer/EOF fixes (fake curl stand-in on PATH, real sh subprocess spawns, real elapsed-time measurements - verified non-vacuous against a hand-reconstructed old-style script), unit tests for readPlanUsageTelemetryEnabled. Adapted two existing tests whose payloads referenced the removed field. Fixed during independent code review: a stray indentation break and a test exercising the wrong (legacy) exporter code path. Docs synced: CLAUDE.md, docs/usage-limits-display-plan.md (old disk-based section marked superseded, kept for history), docs/architecture-invariants.md. Full suite green: 352 files, 6780 passed, 12 skipped, 0 failed. tsc/lint/format:check/frontend-syntax all clean. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
e15e8e43e8
commit
d5b75af628
+61
-27
@@ -39,6 +39,7 @@ import { fileURLToPath } from 'node:url';
|
||||
import type { HookEventType } from './types.js';
|
||||
import { HOOK_TIMEOUT_SECONDS } from './config/auth-config.js';
|
||||
import { dataPath } from './config/instance.js';
|
||||
import { readJsonConfig, SETTINGS_PATH } from './web/route-helpers.js';
|
||||
|
||||
/**
|
||||
* Serializes read-modify-write access to a `settings.local.json` path. Every
|
||||
@@ -912,32 +913,41 @@ export async function applyStatusLineConfig(casePath: string, enabled: boolean):
|
||||
* whenever the script content changes so `ensureStatusLineExporterScript`'s
|
||||
* content comparison rewrites stale copies on next use.
|
||||
*/
|
||||
const STATUSLINE_EXPORTER_SCRIPT_MARKER = 'CODEMAN_STATUSLINE_EXPORTER_V2';
|
||||
const STATUSLINE_EXPORTER_SCRIPT_MARKER = 'CODEMAN_STATUSLINE_EXPORTER_V3';
|
||||
|
||||
function statusLineExporterScriptContent(): string {
|
||||
// The telemetry POST runs in a BACKGROUND subshell so it can never add
|
||||
// latency to a render, and its own stdout/stderr are discarded so they
|
||||
// never leak into the visible statusline. If the pane's env carries
|
||||
// CODEMAN_USER_STATUSLINE_CMD (set via tmux setenv by TmuxManager when
|
||||
// findEffectiveUserStatusLineCommand found the user's own REAL statusLine —
|
||||
// see that function's doc comment), the SAME stdin blob is fed to it and
|
||||
// ITS stdout becomes ours, so the user keeps seeing their own statusline
|
||||
// untouched. Absent that, fall back to the plain "codeman" marker.
|
||||
// Where the telemetry POST runs depends on who owns the footer. When the pane's
|
||||
// env carries CODEMAN_USER_STATUSLINE_CMD (set via tmux setenv by TmuxManager
|
||||
// when findEffectiveUserStatusLineCommand found the user's own REAL statusLine —
|
||||
// see that function's doc comment), the user's command owns the footer, so the
|
||||
// POST runs in a BACKGROUND subshell with stdin/stdout/stderr all closed
|
||||
// (`>/dev/null 2>&1 </dev/null &`) — closing stdout/stderr keeps it from adding
|
||||
// latency or leaking into the visible statusline, and closing stdin too is what
|
||||
// lets a host reading this script's own stdout to EOF (`sh script | cat`) see
|
||||
// that EOF promptly: without it the backgrounded curl keeps the pipe's write end
|
||||
// open until IT exits, so the reader blocks for however long curl takes (measured
|
||||
// ~5s with a stand-in) instead of the ~9ms it takes once stdin is closed too.
|
||||
// Absent a user statusline, NOTHING else will print the footer, so the POST runs
|
||||
// in the FOREGROUND and ITS OWN stdout becomes the footer — `/api/status-telemetry`
|
||||
// returns formatSessionStatusText(...) (model/tokens/context %) precisely so this
|
||||
// can happen — falling back to the plain "codeman" marker only if curl itself
|
||||
// fails (`|| echo codeman`, refused/unreachable Codeman). `--max-time` bounds a
|
||||
// HUNG (not just refused) Codeman so it cannot wedge the render indefinitely.
|
||||
const post =
|
||||
`printf '{"sessionId":"%s","data":%s}' "$CODEMAN_SESSION_ID" "$INPUT" | ` +
|
||||
`curl -sk --max-time 5 -X POST "$CODEMAN_API_URL${STATUSLINE_MARKER}" ` +
|
||||
`-H 'Content-Type: application/json' ` +
|
||||
`-H "X-Codeman-Hook-Secret: $(cat "$CODEMAN_HOOK_SECRET_FILE" 2>/dev/null)" ` +
|
||||
`--data @-`;
|
||||
return (
|
||||
`#!/bin/sh\n` +
|
||||
`# ${STATUSLINE_EXPORTER_SCRIPT_MARKER} — auto-generated by Codeman; safe to delete, regenerated on demand.\n` +
|
||||
`INPUT=$(cat 2>/dev/null || echo '{}')\n` +
|
||||
`(\n` +
|
||||
` printf '{"sessionId":"%s","data":%s}' "$CODEMAN_SESSION_ID" "$INPUT" | ` +
|
||||
`curl -sk -X POST "$CODEMAN_API_URL${STATUSLINE_MARKER}" ` +
|
||||
`-H 'Content-Type: application/json' ` +
|
||||
`-H "X-Codeman-Hook-Secret: $(cat "$CODEMAN_HOOK_SECRET_FILE" 2>/dev/null)" ` +
|
||||
`--data @- >/dev/null 2>&1\n` +
|
||||
`) &\n` +
|
||||
`if [ -n "$CODEMAN_USER_STATUSLINE_CMD" ]; then\n` +
|
||||
` ( ${post} ) >/dev/null 2>&1 </dev/null &\n` +
|
||||
` printf '%s' "$INPUT" | sh -c "$CODEMAN_USER_STATUSLINE_CMD"\n` +
|
||||
`else\n` +
|
||||
` echo codeman\n` +
|
||||
` ${post} 2>/dev/null || echo codeman\n` +
|
||||
`fi\n`
|
||||
);
|
||||
}
|
||||
@@ -1019,13 +1029,34 @@ export async function ensureStatusLineExporterScript(): Promise<string> {
|
||||
return scriptPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether plan-usage telemetry collection is CURRENTLY wanted — read FRESH
|
||||
* from the persisted `showPlanUsageLimits` setting on every call, never
|
||||
* cached and never per-session. Reusing that setting rather than inventing a
|
||||
* second persisted flag: it's the SAME boolean the App Settings chip checkbox
|
||||
* already writes (see `planUsageChipEnabled()` in settings-ui.js).
|
||||
*
|
||||
* This is what lets the on/off decision survive a Codeman restart (there is
|
||||
* no per-session state to lose — see the now-removed `Session._statusLineTelemetry`,
|
||||
* which WAS such a per-session field and went stale on every restart) and
|
||||
* apply uniformly across every claude session-creation path — interactive
|
||||
* create, cron, the Ralph Loop API, quick-start — with none of them needing
|
||||
* to thread a request-time flag through: they all already construct a
|
||||
* session via TmuxManager.createSession/respawnPane, which reads this at
|
||||
* spawn time.
|
||||
*/
|
||||
export async function readPlanUsageTelemetryEnabled(): Promise<boolean> {
|
||||
const settings = await readJsonConfig<Record<string, unknown>>(SETTINGS_PATH, 'settings.json', {});
|
||||
return settings.showPlanUsageLimits === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the statusLine command to pass as an EPHEMERAL `claude --settings`
|
||||
* CLI flag for this one process (see buildClaudeSettingsFlag in
|
||||
* tmux-manager.ts) — never written to disk. This supersedes the old
|
||||
* applyStatusLineConfig(path, true) disk-write: a file-based statusLine
|
||||
* leaked into any plain `claude` run in that directory outside Codeman
|
||||
* entirely (it took precedence over the user's own global/project
|
||||
* CLI flag for this one process (see buildSpawnCommandFromRegistry in
|
||||
* session-cli-registry-bridge.ts) — never written to disk. This supersedes
|
||||
* the old applyStatusLineConfig(path, true) disk-write: a file-based
|
||||
* statusLine leaked into any plain `claude` run in that directory outside
|
||||
* Codeman entirely (it took precedence over the user's own global/project
|
||||
* statusline with no disclosure and no way to remove it — found live
|
||||
* 2026-08-31).
|
||||
*
|
||||
@@ -1033,14 +1064,17 @@ export async function ensureStatusLineExporterScript(): Promise<string> {
|
||||
* exporter into this workspace's settings.local.json, it is stripped here
|
||||
* (isOurs-guarded, same as applyStatusLineConfig's removal branch) so every
|
||||
* workspace migrates off the disk-based mechanism the first time a session
|
||||
* starts there again — no manual cleanup required.
|
||||
* starts there again — no manual cleanup required. This self-heal runs
|
||||
* regardless of `telemetryEnabled`, so a legacy leftover is cleaned up even
|
||||
* while the setting is currently off.
|
||||
*
|
||||
* Returns undefined when telemetry wasn't requested, or when the workspace
|
||||
* already has its OWN hand-configured statusLine (never override a real one).
|
||||
* Returns undefined when telemetry isn't currently enabled (see
|
||||
* readPlanUsageTelemetryEnabled), or when the workspace already has its OWN
|
||||
* hand-configured statusLine (never override a real one).
|
||||
*/
|
||||
export async function resolveStatusLineCliCommand(
|
||||
casePath: string,
|
||||
telemetryRequested: boolean
|
||||
telemetryEnabled: boolean
|
||||
): Promise<string | undefined> {
|
||||
const settingsPath = join(casePath, '.claude', 'settings.local.json');
|
||||
let userHasOwnStatusLine = false;
|
||||
@@ -1059,7 +1093,7 @@ export async function resolveStatusLineCliCommand(
|
||||
// Malformed — leave it alone, same guard applyStatusLineConfig itself uses.
|
||||
}
|
||||
}
|
||||
if (!telemetryRequested || userHasOwnStatusLine) return undefined;
|
||||
if (!telemetryEnabled || userHasOwnStatusLine) return undefined;
|
||||
return ensureStatusLineExporterScript();
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user