context-doctor 0.19.0 → 0.20.0

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.
@@ -0,0 +1,55 @@
1
+ /**
2
+ * `context-doctor autopilot on|off|pause|resume|status`
3
+ *
4
+ * Puts the proxy, in autopilot mode, in front of every Claude Code session on
5
+ * this machine, as a background service that starts at login and restarts if
6
+ * it dies, then points Claude Code at it through ~/.claude/settings.json.
7
+ *
8
+ * Order matters, because a base URL pointing at a dead port breaks every new
9
+ * session: the service is installed and must answer /health BEFORE settings
10
+ * are touched, and `off` removes the setting BEFORE stopping the service.
11
+ * `pause` leaves everything wired and makes the proxy a pure passthrough,
12
+ * which is the way to switch autopilot off without breaking sessions that
13
+ * are already running against it.
14
+ *
15
+ * What this cannot reach, stated rather than implied: Claude Desktop's chat
16
+ * tab and claude.ai (their requests leave from Anthropic's app, not from a
17
+ * process that reads settings.json), Cursor's own models (sent from Cursor's
18
+ * servers), and Codex signed in with ChatGPT (its traffic goes to ChatGPT's
19
+ * backend, not an API base URL).
20
+ */
21
+ export declare const DEFAULT_AUTOPILOT_PORT = 8787;
22
+ export interface AutopilotPaths {
23
+ settings: string;
24
+ config: string;
25
+ pauseFile: string;
26
+ statePath: string;
27
+ log: string;
28
+ }
29
+ export declare function autopilotPaths(home?: string): AutopilotPaths;
30
+ export declare function proxyUrl(port: number): string;
31
+ /** The command the service runs. Absolute paths: services start with an empty PATH. */
32
+ export declare function serviceCommand(node: string, cli: string, port: number, paths: AutopilotPaths): string[];
33
+ export declare function launchdPlist(args: string[], log: string): string;
34
+ export declare function systemdUnit(args: string[], log: string): string;
35
+ export declare function windowsTaskCommand(args: string[]): string;
36
+ export declare function startDetached(args: string[], log: string): void;
37
+ export interface Health {
38
+ ok: boolean;
39
+ autopilot?: boolean;
40
+ version?: string;
41
+ }
42
+ export declare function health(port: number, timeoutMs?: number): Promise<Health>;
43
+ export declare function currentCli(): string;
44
+ export declare function autopilotOn(port?: number, paths?: AutopilotPaths): Promise<{
45
+ ok: boolean;
46
+ lines: string[];
47
+ }>;
48
+ export declare function autopilotOff(paths?: AutopilotPaths): Promise<string[]>;
49
+ export declare function autopilotPause(paused: boolean, paths?: AutopilotPaths): string;
50
+ export declare function autopilotStatus(paths?: AutopilotPaths): Promise<string[]>;
51
+ /**
52
+ * Called by the every-prompt hook: if autopilot is on and the proxy is down,
53
+ * start it before the prompt's request goes out. Returns quickly either way.
54
+ */
55
+ export declare function ensureProxyUp(paths?: AutopilotPaths): Promise<void>;
@@ -0,0 +1,325 @@
1
+ /**
2
+ * `context-doctor autopilot on|off|pause|resume|status`
3
+ *
4
+ * Puts the proxy, in autopilot mode, in front of every Claude Code session on
5
+ * this machine, as a background service that starts at login and restarts if
6
+ * it dies, then points Claude Code at it through ~/.claude/settings.json.
7
+ *
8
+ * Order matters, because a base URL pointing at a dead port breaks every new
9
+ * session: the service is installed and must answer /health BEFORE settings
10
+ * are touched, and `off` removes the setting BEFORE stopping the service.
11
+ * `pause` leaves everything wired and makes the proxy a pure passthrough,
12
+ * which is the way to switch autopilot off without breaking sessions that
13
+ * are already running against it.
14
+ *
15
+ * What this cannot reach, stated rather than implied: Claude Desktop's chat
16
+ * tab and claude.ai (their requests leave from Anthropic's app, not from a
17
+ * process that reads settings.json), Cursor's own models (sent from Cursor's
18
+ * servers), and Codex signed in with ChatGPT (its traffic goes to ChatGPT's
19
+ * backend, not an API base URL).
20
+ */
21
+ import { execFileSync, spawn } from "node:child_process";
22
+ import { existsSync, mkdirSync, openSync, rmSync, writeFileSync } from "node:fs";
23
+ import { homedir, platform } from "node:os";
24
+ import { dirname, join } from "node:path";
25
+ import { fileURLToPath } from "node:url";
26
+ import { isEphemeralPath, readJson, writeJsonWithBackup } from "./install.js";
27
+ import { formatTokens } from "./tokens.js";
28
+ export const DEFAULT_AUTOPILOT_PORT = 8787;
29
+ const LABEL = "com.gai-ventures.context-doctor.proxy";
30
+ export function autopilotPaths(home = homedir()) {
31
+ const dir = join(home, ".claude");
32
+ return {
33
+ settings: join(dir, "settings.json"),
34
+ config: join(dir, ".context-doctor-autopilot.json"),
35
+ pauseFile: join(dir, ".context-doctor-autopilot-paused"),
36
+ statePath: join(dir, ".context-doctor-autopilot-cleared.json"),
37
+ log: join(dir, ".context-doctor-proxy.log"),
38
+ };
39
+ }
40
+ export function proxyUrl(port) {
41
+ return `http://127.0.0.1:${port}`;
42
+ }
43
+ /** The command the service runs. Absolute paths: services start with an empty PATH. */
44
+ export function serviceCommand(node, cli, port, paths) {
45
+ return [node, cli, "proxy", "--autopilot", "--port", String(port), "--autopilot-state", paths.statePath, "--autopilot-pause-file", paths.pauseFile];
46
+ }
47
+ export function launchdPlist(args, log) {
48
+ const esc = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
49
+ return `<?xml version="1.0" encoding="UTF-8"?>
50
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
51
+ <plist version="1.0">
52
+ <dict>
53
+ <key>Label</key><string>${LABEL}</string>
54
+ <key>ProgramArguments</key>
55
+ <array>
56
+ ${args.map((a) => ` <string>${esc(a)}</string>`).join("\n")}
57
+ </array>
58
+ <key>RunAtLoad</key><true/>
59
+ <key>KeepAlive</key><true/>
60
+ <key>ThrottleInterval</key><integer>5</integer>
61
+ <key>StandardOutPath</key><string>${esc(log)}</string>
62
+ <key>StandardErrorPath</key><string>${esc(log)}</string>
63
+ </dict>
64
+ </plist>
65
+ `;
66
+ }
67
+ export function systemdUnit(args, log) {
68
+ const q = (s) => (/[\s"\\]/.test(s) ? `"${s.replace(/(["\\])/g, "\\$1")}"` : s);
69
+ return `[Unit]
70
+ Description=context-doctor proxy (autopilot)
71
+
72
+ [Service]
73
+ ExecStart=${args.map(q).join(" ")}
74
+ Restart=always
75
+ RestartSec=2
76
+ StandardOutput=append:${log}
77
+ StandardError=append:${log}
78
+
79
+ [Install]
80
+ WantedBy=default.target
81
+ `;
82
+ }
83
+ export function windowsTaskCommand(args) {
84
+ return args.map((a) => `"${a}"`).join(" ");
85
+ }
86
+ function servicePaths(home = homedir()) {
87
+ return {
88
+ plist: join(home, "Library", "LaunchAgents", `${LABEL}.plist`),
89
+ unit: join(home, ".config", "systemd", "user", "context-doctor-proxy.service"),
90
+ };
91
+ }
92
+ function uid() {
93
+ return String(process.getuid?.() ?? "");
94
+ }
95
+ function run(cmd, args) {
96
+ execFileSync(cmd, args, { stdio: "ignore" });
97
+ }
98
+ function tryRun(cmd, args) {
99
+ try {
100
+ run(cmd, args);
101
+ return true;
102
+ }
103
+ catch {
104
+ return false;
105
+ }
106
+ }
107
+ /** Install and start the background service for this platform. */
108
+ function installService(args, log) {
109
+ const os = platform();
110
+ const sp = servicePaths();
111
+ if (os === "darwin") {
112
+ mkdirSync(dirname(sp.plist), { recursive: true });
113
+ writeFileSync(sp.plist, launchdPlist(args, log));
114
+ tryRun("launchctl", ["bootout", `gui/${uid()}`, sp.plist]);
115
+ run("launchctl", ["bootstrap", `gui/${uid()}`, sp.plist]);
116
+ return `launchd agent ${sp.plist}`;
117
+ }
118
+ if (os === "linux") {
119
+ mkdirSync(dirname(sp.unit), { recursive: true });
120
+ writeFileSync(sp.unit, systemdUnit(args, log));
121
+ if (tryRun("systemctl", ["--user", "daemon-reload"]) && tryRun("systemctl", ["--user", "enable", "--now", "context-doctor-proxy.service"])) {
122
+ tryRun("systemctl", ["--user", "restart", "context-doctor-proxy.service"]);
123
+ return `systemd user service ${sp.unit}`;
124
+ }
125
+ startDetached(args, log);
126
+ return "detached process (systemd --user unavailable; the every-prompt hook restarts it if it stops)";
127
+ }
128
+ if (os === "win32") {
129
+ tryRun("schtasks", ["/Create", "/F", "/SC", "ONLOGON", "/TN", "context-doctor-proxy", "/TR", windowsTaskCommand(args)]);
130
+ startDetached(args, log);
131
+ return "logon task context-doctor-proxy + detached process now";
132
+ }
133
+ startDetached(args, log);
134
+ return "detached process";
135
+ }
136
+ function removeService() {
137
+ const os = platform();
138
+ const sp = servicePaths();
139
+ if (os === "darwin") {
140
+ tryRun("launchctl", ["bootout", `gui/${uid()}`, sp.plist]);
141
+ if (existsSync(sp.plist))
142
+ rmSync(sp.plist);
143
+ }
144
+ else if (os === "linux") {
145
+ tryRun("systemctl", ["--user", "disable", "--now", "context-doctor-proxy.service"]);
146
+ if (existsSync(sp.unit))
147
+ rmSync(sp.unit);
148
+ tryRun("systemctl", ["--user", "daemon-reload"]);
149
+ }
150
+ else if (os === "win32") {
151
+ tryRun("schtasks", ["/Delete", "/F", "/TN", "context-doctor-proxy"]);
152
+ }
153
+ }
154
+ export function startDetached(args, log) {
155
+ mkdirSync(dirname(log), { recursive: true });
156
+ const out = openSync(log, "a");
157
+ const child = spawn(args[0], args.slice(1), { detached: true, stdio: ["ignore", out, out], windowsHide: true });
158
+ child.unref();
159
+ }
160
+ export async function health(port, timeoutMs = 800) {
161
+ try {
162
+ const r = await fetch(`${proxyUrl(port)}/health`, { signal: AbortSignal.timeout(timeoutMs) });
163
+ const j = (await r.json());
164
+ return { ok: Boolean(j.ok && j.service === "context-doctor-proxy"), autopilot: j.autopilot, version: j.version };
165
+ }
166
+ catch {
167
+ return { ok: false };
168
+ }
169
+ }
170
+ async function waitHealthy(port, ms) {
171
+ const end = Date.now() + ms;
172
+ for (;;) {
173
+ const h = await health(port);
174
+ if (h.ok && h.autopilot)
175
+ return h;
176
+ if (Date.now() > end)
177
+ return h;
178
+ await new Promise((r) => setTimeout(r, 250));
179
+ }
180
+ }
181
+ function readConfig(paths) {
182
+ try {
183
+ return readJson(paths.config);
184
+ }
185
+ catch {
186
+ return undefined;
187
+ }
188
+ }
189
+ export function currentCli() {
190
+ return join(dirname(fileURLToPath(import.meta.url)), "cli.js");
191
+ }
192
+ export async function autopilotOn(port = DEFAULT_AUTOPILOT_PORT, paths = autopilotPaths()) {
193
+ const lines = [];
194
+ const cli = currentCli();
195
+ if (isEphemeralPath(cli)) {
196
+ return {
197
+ ok: false,
198
+ lines: [
199
+ "✗ Running from the npx cache, which npm deletes at will; a background service cannot point there.",
200
+ " Install it once, then re-run: npm install -g context-doctor && context-doctor autopilot on",
201
+ ],
202
+ };
203
+ }
204
+ const existing = await health(port);
205
+ if (existing.ok && !existing.autopilot) {
206
+ return { ok: false, lines: [`✗ A context-doctor proxy without autopilot is already on port ${port}. Stop it, or pass --port.`] };
207
+ }
208
+ if (!existing.ok) {
209
+ // Something that is not us on the port would receive Claude Code's traffic.
210
+ try {
211
+ await fetch(proxyUrl(port), { signal: AbortSignal.timeout(500) });
212
+ return { ok: false, lines: [`✗ Port ${port} is taken by another program. Pass --port <free port>.`] };
213
+ }
214
+ catch {
215
+ /* nothing listening: good */
216
+ }
217
+ }
218
+ const args = serviceCommand(process.execPath, cli, port, paths);
219
+ const how = installService(args, paths.log);
220
+ const h = await waitHealthy(port, 15_000);
221
+ if (!h.ok || !h.autopilot) {
222
+ removeService();
223
+ return { ok: false, lines: [`✗ The proxy did not come up on port ${port} (log: ${paths.log}). Nothing was changed in Claude Code's settings.`] };
224
+ }
225
+ lines.push(`✓ Proxy running with autopilot on ${proxyUrl(port)} (${how})`);
226
+ const settings = readJson(paths.settings);
227
+ const env = (settings.env ??= {});
228
+ const prev = readConfig(paths);
229
+ const previousBaseUrl = prev?.previousBaseUrl !== undefined ? prev.previousBaseUrl : (env.ANTHROPIC_BASE_URL ?? null);
230
+ if (previousBaseUrl && previousBaseUrl !== proxyUrl(port)) {
231
+ lines.push(`! settings.json already routed Claude Code to ${previousBaseUrl}; autopilot forwards to api.anthropic.com instead. \`autopilot off\` restores it.`);
232
+ }
233
+ env.ANTHROPIC_BASE_URL = proxyUrl(port);
234
+ writeJsonWithBackup(paths.settings, settings);
235
+ writeFileSync(paths.config, JSON.stringify({ port, previousBaseUrl, node: process.execPath, cli }, null, 2));
236
+ if (existsSync(paths.pauseFile))
237
+ rmSync(paths.pauseFile);
238
+ lines.push(`✓ Claude Code routed through it (env.ANTHROPIC_BASE_URL in ${paths.settings})`);
239
+ lines.push(" Applies to Claude Code sessions started from now on (CLI, IDE, and the desktop app's Code tab).");
240
+ lines.push(" Sessions already open keep their old route until restarted.");
241
+ lines.push(` GPT apps on your own OpenAI key get the same: export OPENAI_BASE_URL=${proxyUrl(port)}/v1`);
242
+ return { ok: true, lines };
243
+ }
244
+ export async function autopilotOff(paths = autopilotPaths()) {
245
+ const lines = [];
246
+ const cfg = readConfig(paths);
247
+ const settings = readJson(paths.settings);
248
+ const env = settings.env;
249
+ if (env?.ANTHROPIC_BASE_URL && cfg && env.ANTHROPIC_BASE_URL === proxyUrl(cfg.port)) {
250
+ if (cfg.previousBaseUrl)
251
+ env.ANTHROPIC_BASE_URL = cfg.previousBaseUrl;
252
+ else
253
+ delete env.ANTHROPIC_BASE_URL;
254
+ if (Object.keys(env).length === 0)
255
+ delete settings.env;
256
+ writeJsonWithBackup(paths.settings, settings);
257
+ lines.push("✓ Claude Code no longer routed through the proxy (new sessions)");
258
+ }
259
+ removeService();
260
+ if (existsSync(paths.config))
261
+ rmSync(paths.config);
262
+ if (existsSync(paths.pauseFile))
263
+ rmSync(paths.pauseFile);
264
+ lines.push("✓ Background service removed");
265
+ lines.push(" Sessions started while autopilot was on still point at the proxy: restart them.");
266
+ lines.push(" To switch off without restarting anything, use `autopilot pause` instead.");
267
+ return lines;
268
+ }
269
+ export function autopilotPause(paused, paths = autopilotPaths()) {
270
+ if (paused) {
271
+ writeFileSync(paths.pauseFile, new Date().toISOString());
272
+ return "✓ Paused: the proxy now forwards every request unchanged. `autopilot resume` turns it back on.";
273
+ }
274
+ if (existsSync(paths.pauseFile))
275
+ rmSync(paths.pauseFile);
276
+ return "✓ Resumed: stale tool output is cleared again.";
277
+ }
278
+ export async function autopilotStatus(paths = autopilotPaths()) {
279
+ const cfg = readConfig(paths);
280
+ if (!cfg)
281
+ return ["Autopilot is off. `context-doctor autopilot on` turns it on for every new Claude Code session."];
282
+ const lines = [];
283
+ const h = await health(cfg.port);
284
+ const env = (readJson(paths.settings).env ?? {});
285
+ lines.push(`${h.ok ? "✓" : "✗"} Proxy ${proxyUrl(cfg.port)} ${h.ok ? `up (v${h.version ?? "?"})` : "NOT RESPONDING (the next Claude Code prompt restarts it via the hook)"}`);
286
+ lines.push(`${env.ANTHROPIC_BASE_URL === proxyUrl(cfg.port) ? "✓" : "✗"} Claude Code routed through it`);
287
+ if (existsSync(paths.pauseFile))
288
+ lines.push("! Paused: requests pass through unchanged");
289
+ if (h.ok) {
290
+ try {
291
+ const s = (await fetch(`${proxyUrl(cfg.port)}/stats`, { signal: AbortSignal.timeout(1000) }).then((r) => r.json()));
292
+ const a = s.autopilot;
293
+ if (a) {
294
+ lines.push("");
295
+ lines.push(`Since ${s.startedAt.slice(0, 16).replace("T", " ")} UTC: ${a.requests} requests, ${a.changedRequests} sent lighter`);
296
+ lines.push(` ${a.resultsCleared} stale tool outputs cleared in ${a.batches} batches (${a.coldBatches} while the cache was cold anyway)`);
297
+ lines.push(` ~${formatTokens(a.tokensRemoved)} tokens not sent`);
298
+ const billed = s.upstreamInputTokens + s.upstreamCacheReadTokens + s.upstreamCacheWriteTokens;
299
+ if (billed > 0)
300
+ lines.push(` Billed input: ${formatTokens(billed)} (${Math.round((s.upstreamCacheReadTokens / billed) * 100)}% cache reads)`);
301
+ if (a.lastReason)
302
+ lines.push(` Last decision: ${a.lastReason}`);
303
+ }
304
+ }
305
+ catch {
306
+ /* stats are informational */
307
+ }
308
+ }
309
+ return lines;
310
+ }
311
+ /**
312
+ * Called by the every-prompt hook: if autopilot is on and the proxy is down,
313
+ * start it before the prompt's request goes out. Returns quickly either way.
314
+ */
315
+ export async function ensureProxyUp(paths = autopilotPaths()) {
316
+ const cfg = readConfig(paths);
317
+ if (!cfg)
318
+ return;
319
+ if ((await health(cfg.port, 300)).ok)
320
+ return;
321
+ if (!existsSync(cfg.node) || !existsSync(cfg.cli))
322
+ return;
323
+ startDetached(serviceCommand(cfg.node, cfg.cli, cfg.port, paths), paths.log);
324
+ await waitHealthy(cfg.port, 2500);
325
+ }
package/dist/cli.js CHANGED
@@ -21,6 +21,7 @@ import { listSessions, parseSessionFile } from "./session.js";
21
21
  import { runHook } from "./hook.js";
22
22
  import { buildImpactReport } from "./impact.js";
23
23
  import { measureTokenizer, renderTokenizer } from "./tokenizer-measure.js";
24
+ import { autopilotOff, autopilotOn, autopilotPause, autopilotStatus, DEFAULT_AUTOPILOT_PORT } from "./autopilot.js";
24
25
  import { renderPreferences, copyToClipboard, CHAT_PREFERENCES } from "./preferences.js";
25
26
  import { recordLedger } from "./ledger.js";
26
27
  import { runDoctor } from "./doctor.js";
@@ -48,6 +49,10 @@ Usage:
48
49
  context-doctor install Wire the MCP server + skill into Claude Desktop,
49
50
  Claude Code, and Cursor automatically
50
51
  context-doctor uninstall Undo install
52
+ context-doctor autopilot on|off|pause|resume|status
53
+ Every new Claude Code session goes through the
54
+ local proxy, which clears stale tool output only
55
+ when the prompt cache is cold (never costs more)
51
56
  context-doctor instructions [--copy] Standing context rules to paste into claude.ai or
52
57
  ChatGPT preferences (works on web and mobile too)
53
58
  context-doctor session [file] Profile a Claude Code session transcript or a
@@ -206,6 +211,15 @@ function parseArgs(argv) {
206
211
  case "--token":
207
212
  args.token = argv[++i];
208
213
  break;
214
+ case "--autopilot":
215
+ args.autopilot = true;
216
+ break;
217
+ case "--autopilot-state":
218
+ args.autopilotState = argv[++i];
219
+ break;
220
+ case "--autopilot-pause-file":
221
+ args.autopilotPauseFile = argv[++i];
222
+ break;
209
223
  case "--config":
210
224
  args.config = argv[++i];
211
225
  break;
@@ -252,7 +266,7 @@ function readInput(file) {
252
266
  return readFileSync(0, "utf8");
253
267
  return readFileSync(file, "utf8");
254
268
  }
255
- function main() {
269
+ async function main() {
256
270
  const args = parseArgs(process.argv.slice(2));
257
271
  if (args.command === "hook") {
258
272
  void runHook();
@@ -445,6 +459,30 @@ function main() {
445
459
  process.exitCode = 1;
446
460
  return;
447
461
  }
462
+ if (args.command === "autopilot") {
463
+ const sub = args.file ?? "status";
464
+ const port = args.port ?? DEFAULT_AUTOPILOT_PORT;
465
+ if (sub === "on") {
466
+ const r = await autopilotOn(port);
467
+ console.log(r.lines.join("\n"));
468
+ if (!r.ok)
469
+ process.exitCode = 1;
470
+ }
471
+ else if (sub === "off") {
472
+ console.log((await autopilotOff()).join("\n"));
473
+ }
474
+ else if (sub === "pause" || sub === "resume") {
475
+ console.log(autopilotPause(sub === "pause"));
476
+ }
477
+ else if (sub === "status") {
478
+ console.log((await autopilotStatus()).join("\n"));
479
+ }
480
+ else {
481
+ console.error("Usage: context-doctor autopilot on|off|pause|resume|status [--port n]");
482
+ process.exitCode = 1;
483
+ }
484
+ return;
485
+ }
448
486
  if (args.command === "instructions") {
449
487
  console.log(renderPreferences(args.copy ? copyToClipboard(CHAT_PREFERENCES) : undefined));
450
488
  return;
@@ -470,6 +508,9 @@ function main() {
470
508
  port: args.port,
471
509
  host: args.host,
472
510
  token: args.token ?? process.env.CONTEXT_DOCTOR_PROXY_TOKEN,
511
+ autopilot: args.autopilot,
512
+ autopilotStatePath: args.autopilotState,
513
+ autopilotPauseFile: args.autopilotPauseFile,
473
514
  anthropicUpstream: args.upstreamAnthropic,
474
515
  openaiUpstream: args.upstreamOpenai,
475
516
  strategies: args.strategies.length > 0 ? args.strategies : loadedRc.config.strategies,
@@ -556,4 +597,4 @@ function main() {
556
597
  console.log(HELP);
557
598
  process.exit(1);
558
599
  }
559
- main();
600
+ void main();
package/dist/doctor.js CHANGED
@@ -5,6 +5,7 @@
5
5
  * check, so "it doesn't work" becomes a single pasteable diagnosis. Always
6
6
  * exits 0 — absence of an app is a note, not a failure.
7
7
  */
8
+ import { autopilotPaths, health, proxyUrl } from "./autopilot.js";
8
9
  import { spawn } from "node:child_process";
9
10
  import { existsSync, readFileSync } from "node:fs";
10
11
  import { homedir, platform } from "node:os";
@@ -209,6 +210,27 @@ export async function runDoctor() {
209
210
  checks.push({ label: "Project config", status: "skip", detail: "no .contextdoctorrc (optional; create one with: context-doctor init <preset>)" });
210
211
  }
211
212
  checks.push(await checkMcpHandshake());
213
+ // Autopilot: optional, so "off" is a skip, not a failure. When it is on, a
214
+ // dead proxy or a settings file pointing elsewhere is a real problem.
215
+ {
216
+ const paths = autopilotPaths();
217
+ if (!existsSync(paths.config)) {
218
+ checks.push({ label: "Autopilot", status: "skip", detail: "off (context-doctor autopilot on: clears stale tool output in every new Claude Code session)" });
219
+ }
220
+ else {
221
+ const cfg = JSON.parse(readFileSync(paths.config, "utf8"));
222
+ const h = await health(cfg.port);
223
+ let routed = false;
224
+ try {
225
+ routed = JSON.parse(readFileSync(paths.settings, "utf8"))?.env?.ANTHROPIC_BASE_URL === proxyUrl(cfg.port);
226
+ }
227
+ catch { /* reported below */ }
228
+ const paused = existsSync(paths.pauseFile);
229
+ checks.push(h.ok && h.autopilot && routed
230
+ ? { label: "Autopilot", status: "ok", detail: `proxy up on ${proxyUrl(cfg.port)}, Claude Code routed through it${paused ? " (PAUSED: passthrough)" : ""}` }
231
+ : { label: "Autopilot", status: "fail", detail: !h.ok ? `proxy not answering on ${proxyUrl(cfg.port)}: the next Claude Code prompt restarts it; or run: context-doctor autopilot on` : "settings.json no longer routes Claude Code to the proxy: run context-doctor autopilot on" });
232
+ }
233
+ }
212
234
  const mark = { ok: "✓", fail: "✗", skip: "–" };
213
235
  console.log("CONTEXT DOCTOR — self-check");
214
236
  console.log("═".repeat(56));
package/dist/hook.js CHANGED
@@ -18,6 +18,7 @@ import { parseConversation } from "./parse.js";
18
18
  import { profileConversation } from "./profile.js";
19
19
  import { parseSessionFile } from "./session.js";
20
20
  import { formatTokens, CHARS_PER_TOKEN } from "./tokens.js";
21
+ import { ensureProxyUp } from "./autopilot.js";
21
22
  import { formatUsd } from "./pricing.js";
22
23
  import { checkBudget, loadConfig } from "./config.js";
23
24
  /** Default nudge threshold; a project budget or env var can lower/raise it. */
@@ -121,7 +122,12 @@ async function readStdin() {
121
122
  export async function runHook() {
122
123
  // A hook must never break the user's prompt: any failure exits silently.
123
124
  try {
125
+ // Autopilot self-heal: this prompt's request is about to go to the proxy,
126
+ // so if the proxy died, start it now. One existsSync when autopilot is
127
+ // off; a ~1ms localhost health check when it is on and healthy.
128
+ const heal = ensureProxyUp().catch(() => undefined);
124
129
  const input = JSON.parse(await readStdin());
130
+ await heal;
125
131
  const transcriptPath = input.transcript_path;
126
132
  if (!transcriptPath || !existsSync(transcriptPath))
127
133
  return;
package/dist/index.d.ts CHANGED
@@ -21,3 +21,5 @@ export { renderStatusLine, tailUsage } from "./statusline.js";
21
21
  export * from "./sketch.js";
22
22
  export * from "./preferences.js";
23
23
  export * from "./tokenizer-measure.js";
24
+ export * from "./autoclear.js";
25
+ export * from "./autopilot.js";
package/dist/index.js CHANGED
@@ -16,3 +16,5 @@ export { renderStatusLine, tailUsage } from "./statusline.js";
16
16
  export * from "./sketch.js";
17
17
  export * from "./preferences.js";
18
18
  export * from "./tokenizer-measure.js";
19
+ export * from "./autoclear.js";
20
+ export * from "./autopilot.js";
package/dist/install.d.ts CHANGED
@@ -20,6 +20,10 @@ export declare function npxLauncher(platformName: string): {
20
20
  command: string;
21
21
  args: string[];
22
22
  };
23
+ export declare function readJson(path: string): Record<string, any>;
24
+ export declare function writeJsonWithBackup(path: string, data: Record<string, any>): void;
25
+ /** Paths npm may delete at any time: the npx cache and the npm cache itself. */
26
+ export declare function isEphemeralPath(path: string): boolean;
23
27
  /**
24
28
  * Claude Code's status bar: a `statusLine` command whose stdout is shown while
25
29
  * the user types. Opt-in, because there is only one status line and it may
package/dist/install.js CHANGED
@@ -63,7 +63,7 @@ export function npxLauncher(platformName) {
63
63
  ? { command: "cmd", args: ["/c", "npx", "-y", "context-doctor-mcp"] }
64
64
  : { command: "npx", args: ["-y", "context-doctor-mcp"] };
65
65
  }
66
- function readJson(path) {
66
+ export function readJson(path) {
67
67
  if (!existsSync(path))
68
68
  return {};
69
69
  try {
@@ -73,7 +73,7 @@ function readJson(path) {
73
73
  throw new Error(`${path} exists but is not valid JSON — fix or remove it first (${e.message})`);
74
74
  }
75
75
  }
76
- function writeJsonWithBackup(path, data) {
76
+ export function writeJsonWithBackup(path, data) {
77
77
  mkdirSync(dirname(path), { recursive: true });
78
78
  if (existsSync(path))
79
79
  copyFileSync(path, path + ".context-doctor.backup");
@@ -105,7 +105,7 @@ function binOnPath(name) {
105
105
  return null;
106
106
  }
107
107
  /** Paths npm may delete at any time: the npx cache and the npm cache itself. */
108
- function isEphemeralPath(path) {
108
+ export function isEphemeralPath(path) {
109
109
  return /[\\/]_npx[\\/]/.test(path) || /[\\/]\.npm[\\/]/.test(path) || /[\\/]npm-cache[\\/]/i.test(path);
110
110
  }
111
111
  /**
package/dist/mcp.js CHANGED
@@ -49,7 +49,7 @@ const STRATEGY_IDS = ["dedupe", "trim-tool-results", "trim-tool-calls", "strip-b
49
49
  * recommended pattern.
50
50
  */
51
51
  function createServer() {
52
- const server = new McpServer({ name: "context-doctor", version: "0.19.0" }, { instructions: SERVER_INSTRUCTIONS });
52
+ const server = new McpServer({ name: "context-doctor", version: "0.20.0" }, { instructions: SERVER_INSTRUCTIONS });
53
53
  server.tool("profile_context", "Profile an LLM conversation or prompt: token breakdown, largest blocks, and actionable findings about wasted context (duplicates, oversized pastes or tool results, base64 blobs, long history). Two inputs, pass ONE: `conversation` (full OpenAI/Anthropic JSON or raw text, for agents, files and proxies) or `sketch` (for chat apps such as Claude Desktop or ChatGPT where you cannot export the conversation: the turn count plus the few blocks that matter, ~120 tokens to write). Call it whenever the user asks about token usage, context size, cost, speed or limits, and on your own once the conversation passes ~30 turns or holds 3+ large pastes. Act on the top finding in your reply.", {
54
54
  conversation: z.string().optional().describe("Conversation JSON (OpenAI or Anthropic format, or bare message array) or raw prompt text. Omit in chat apps and pass `sketch`."),
55
55
  sketch: z.object({
package/dist/proxy.d.ts CHANGED
@@ -32,9 +32,22 @@ export interface ProxyOptions extends OptimizeOptions {
32
32
  * constant time; a wrong or missing prefix gets 401 and no upstream call.
33
33
  */
34
34
  token?: string;
35
+ /**
36
+ * Autopilot: instead of the general strategies, run only the cache-aware
37
+ * stale-tool-output clearing (autoclear.ts), which replays of real sessions
38
+ * showed never costs more than it saves. Anthropic Messages, OpenAI Chat
39
+ * Completions and OpenAI Responses requests; everything else passes through.
40
+ */
41
+ autopilot?: boolean;
42
+ /** Where autopilot remembers cleared tool results across restarts. */
43
+ autopilotStatePath?: string;
44
+ /** While this file exists, autopilot forwards every request unchanged (instant, restart-free off switch). */
45
+ autopilotPauseFile?: string;
35
46
  anthropicUpstream?: string;
36
47
  openaiUpstream?: string;
37
48
  }
49
+ /** Reported by /health so `autopilot status` can tell an outdated service from a current one. */
50
+ export declare const PROXY_VERSION = "0.20.0";
38
51
  /**
39
52
  * Remove a leading `/t/<token>` from a request path, or return undefined when
40
53
  * the prefix is absent or the token differs. The comparison is constant time
@@ -55,6 +68,21 @@ export interface ProxyStats {
55
68
  upstreamOutputTokens: number;
56
69
  /** Prompt-cache advisories observed on live traffic (unique, capped). */
57
70
  advice: string[];
71
+ /** Cache reads/writes reported upstream (Anthropic), so autopilot's effect on the cache is visible. */
72
+ upstreamCacheReadTokens: number;
73
+ upstreamCacheWriteTokens: number;
74
+ autopilot?: {
75
+ enabled: boolean;
76
+ paused: boolean;
77
+ requests: number;
78
+ changedRequests: number;
79
+ batches: number;
80
+ coldBatches: number;
81
+ resultsCleared: number;
82
+ /** Tokens removed from requests, summed over requests (what was not sent). */
83
+ tokensRemoved: number;
84
+ lastReason: string;
85
+ };
58
86
  }
59
87
  /** Per-model-prefix strategy overrides for the proxy (`--config`). */
60
88
  export interface RouteConfig {