claude-code-cache-fix 3.0.5 → 3.1.1

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/README.ko.md CHANGED
@@ -125,7 +125,7 @@ VSIX 없이 수동 VS Code 래퍼를 설정하려면 [docs/preload-setup.md](doc
125
125
 
126
126
  **하는 것:** 캐시 버그 수정을 위해 발신 요청 구조(블록 순서, 핑거프린트, TTL, git-status)를 수정합니다. 모니터링을 위해 응답 헤더와 SSE 사용량 데이터를 읽습니다.
127
127
 
128
- **하지 않는 것:** 프록시 또는 인터셉터에서 네트워크 호출을 하지 않습니다. 모든 텔레메트리는 `~/.claude/` 아래 로컬 파일에 기록됩니다. [claude-code-meter](https://github.com/cnighswonger/claude-code-meter) 공유에 명시적으로 동의하지 않는 한 데이터가 외부로 전송되지 않습니다(별도 패키지, 대화형 동의 필요).
128
+ **하지 않는 것:** 프록시 또는 인터셉터에서 네트워크 호출을 하지 않습니다. 모든 텔레메트리는 `~/.claude/` 아래 로컬 파일에 기록됩니다. 데이터는 사용자의 컴퓨터를 떠나지 않습니다.
129
129
 
130
130
  **공급망:** 프록시 모드: `proxy/extensions/`에 7개 소형 확장 모듈(각 200줄 미만). 프리로드 모드: 단일 비축소 파일(`preload.mjs`, ~1,700줄). 개발 의존성 1개(`zod`, 테스트 스키마 검증용). 설치 전 코드를 직접 검토하십시오. npm provenance로 각 버전이 소스 커밋에 연결됩니다.
131
131
 
package/README.md CHANGED
@@ -45,40 +45,44 @@ Extensions are hot-reloadable — add, remove, or modify `.mjs` files in `proxy/
45
45
 
46
46
  ### Running as a service
47
47
 
48
- **Linux (systemdrecommended):**
48
+ **Recommended (Linux/macOS)`install-service` subcommand:**
49
49
 
50
- Create `~/.config/systemd/user/cache-fix-proxy.service`:
50
+ ```bash
51
+ cache-fix-proxy install-service
52
+ ```
51
53
 
52
- ```ini
53
- [Unit]
54
- Description=Claude Code Cache Fix Proxy (v3.x)
55
- After=network.target
54
+ Detects your platform and writes the appropriate config:
56
55
 
57
- [Service]
58
- Type=simple
59
- ExecStart=/usr/local/bin/node /path/to/claude-code-cache-fix/proxy/server.mjs
60
- Restart=on-failure
61
- RestartSec=5
62
- Environment=CACHE_FIX_PROXY_PORT=9801
56
+ - **Linux** → `~/.config/systemd/user/cache-fix-proxy.service` (systemd user unit)
57
+ - **macOS** → `~/Library/LaunchAgents/com.cnighswonger.cache-fix-proxy.plist` (launchd agent)
63
58
 
64
- [Install]
65
- WantedBy=default.target
66
- ```
59
+ The output prints the next-step commands to enable and start the service. On Linux:
67
60
 
68
61
  ```bash
69
62
  systemctl --user daemon-reload
70
63
  systemctl --user enable --now cache-fix-proxy
64
+ systemctl --user enable --now cache-fix-proxy-healthcheck.timer # auto-recovery — see below
65
+ sudo loginctl enable-linger $USER # optional: start on boot, not just on login
66
+ ```
67
+
68
+ **Auto-recovery (Linux):** `install-service` also drops a healthcheck companion (`cache-fix-proxy-healthcheck.service` + `.timer`). The timer fires every 2 minutes; the oneshot service runs `curl -fs http://127.0.0.1:<port>/health` and `systemctl --user start cache-fix-proxy.service` if the probe fails. This recovers the proxy from any stop — clean or unclean, expected or unexpected — within 2 minutes. Background: `Restart=on-failure` doesn't fire on clean stops, so before this companion existed, a `systemctl stop` from any source (including unidentified ones during an Anthropic outage on 2026-04-25) would leave the proxy down indefinitely. macOS doesn't need the companion — launchd's `KeepAlive` already auto-restarts on any exit.
71
69
 
72
- # Optional: start on boot (before login)
73
- sudo loginctl enable-linger $USER
70
+ On macOS:
71
+
72
+ ```bash
73
+ launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.cnighswonger.cache-fix-proxy.plist
74
+ launchctl enable gui/$(id -u)/com.cnighswonger.cache-fix-proxy
75
+ launchctl kickstart gui/$(id -u)/com.cnighswonger.cache-fix-proxy
74
76
  ```
75
77
 
76
- A `cache-fix-proxy install-service` subcommand is planned for v3.1.0 ([#48](https://github.com/cnighswonger/claude-code-cache-fix/issues/48)).
78
+ The installed config picks up `CACHE_FIX_PROXY_PORT`, `CACHE_FIX_PROXY_UPSTREAM`, and `CACHE_FIX_DEBUG` from the env at install time. Re-run `install-service --force` to regenerate after env changes, or edit the service file directly. Pair with `cache-fix-proxy uninstall-service` to remove cleanly (stops, disables, deletes).
77
79
 
78
- **Fallback (any OS):**
80
+ The service runs `cache-fix-proxy server` in the foreground, which is just the proxy without the wrapper-mode claude launcher.
81
+
82
+ **Manual (any platform):**
79
83
 
80
84
  ```bash
81
- nohup node "$(npm root -g)/claude-code-cache-fix/proxy/server.mjs" > /tmp/cache-fix-proxy.log 2>&1 &
85
+ nohup cache-fix-proxy server > /tmp/cache-fix-proxy.log 2>&1 &
82
86
  echo 'export ANTHROPIC_BASE_URL=http://127.0.0.1:9801' >> ~/.bashrc
83
87
  ```
84
88
 
@@ -162,7 +166,7 @@ For manual VS Code wrapper setup (without the VSIX), see [docs/preload-setup.md]
162
166
 
163
167
  **What it does:** Modifies outgoing request structure (block order, fingerprint, TTL, git-status) to fix cache bugs. Reads response headers and SSE usage data for monitoring.
164
168
 
165
- **What it does NOT do:** No network calls from the proxy or interceptor. All telemetry is written to local files under `~/.claude/`. No data leaves your machine unless you explicitly opt in to [claude-code-meter](https://github.com/cnighswonger/claude-code-meter) sharing (separate package, requires interactive consent).
169
+ **What it does NOT do:** No network calls from the proxy or interceptor. All telemetry is written to local files under `~/.claude/`. No data leaves your machine.
166
170
 
167
171
  **Supply chain:** Proxy mode: 7 small extension modules in `proxy/extensions/` (each under 200 lines). Preload mode: single unminified file (`preload.mjs`, ~1,700 lines). One dev dependency (`zod` for schema validation in tests only). Review before installing. npm provenance links each published version to its source commit.
168
172
 
@@ -281,6 +285,8 @@ export CLAUDE_CODE_DISABLE_GIT_INSTRUCTIONS=1
281
285
 
282
286
  Or add `"includeGitInstructions": false` to `~/.claude/settings.json`. Claude Code can still run `git status` via the Bash tool when it needs context. Community-validated by [@wadabum](https://github.com/cnighswonger/claude-code-cache-fix/issues/11): 18-token cache creation across git state changes (vs thousands without the flag).
283
287
 
288
+ **Why we don't ship a proxy extension for this:** the proxy intercepts requests after Claude Code has already composed the system prompt — by then the volatile `git status` text is already part of the prefix that the model conditioned on in the previous turn, and stripping it post-hoc would itself bust the cache. The fix has to happen at the source. `CLAUDE_CODE_DISABLE_GIT_INSTRUCTIONS=1` prevents the injection before the prompt is composed, which is why the native flag is the right tool. Stripping post-hoc would also remove model-visible context that an explicit Bash call can recover, and would risk false-positive matches against assistant-written text.
289
+
284
290
  ## Image stripping (preload mode)
285
291
 
286
292
  Images read via the Read tool persist as base64 in conversation history, riding along on every subsequent API call. A single 500KB image costs ~62,500 tokens per turn on Opus 4.6, and **~85,000+ on Opus 4.7** due to the new tokenizer. Image stripping is strongly recommended on 4.7.
package/README.zh.md CHANGED
@@ -137,7 +137,7 @@ NODE_OPTIONS="--import claude-code-cache-fix" claude
137
137
 
138
138
  **它做什么:** 修改出站请求结构(块排序、指纹、TTL、git-status)以修复缓存 bug。读取响应头和 SSE 使用量数据用于监控。
139
139
 
140
- **它不做什么:** 代理或拦截器不会发起网络调用。所有遥测数据写入 `~/.claude/` 下的本地文件。除非你明确选择加入 [claude-code-meter](https://github.com/cnighswonger/claude-code-meter) 共享(独立包,需要交互式同意),否则数据不会离开你的机器。
140
+ **它不做什么:** 代理或拦截器不会发起网络调用。所有遥测数据写入 `~/.claude/` 下的本地文件。数据不会离开你的机器。
141
141
 
142
142
  **供应链:** 代理模式:7 个小型扩展模块在 `proxy/extensions/` 中(每个不到 200 行)。预加载模式:单个未压缩文件(`preload.mjs`,约 1,700 行)。一个开发依赖(`zod`,仅用于测试中的模式验证)。安装前请审查代码。npm 出处(provenance)将每个发布版本链接到其源代码提交。
143
143
 
@@ -9,6 +9,63 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
9
9
  const SERVER_PATH = resolve(__dirname, "../proxy/server.mjs");
10
10
 
11
11
  const args = process.argv.slice(2);
12
+ const SUBCOMMAND = args[0];
13
+
14
+ // Subcommand dispatch (must come before the wrapper-arg parser so subcommand
15
+ // names don't get treated as claude args). Returns null when no subcommand
16
+ // matched, signaling fall-through to wrapper mode below.
17
+ async function dispatch() {
18
+ if (SUBCOMMAND === "server") {
19
+ return new Promise((resolveP) => {
20
+ const serverProc = spawn(process.execPath, [SERVER_PATH, ...args.slice(1)], {
21
+ stdio: "inherit",
22
+ env: process.env,
23
+ });
24
+ serverProc.on("close", (code) => resolveP(code ?? 0));
25
+ serverProc.on("error", (err) => {
26
+ process.stderr.write(`Failed to start proxy server: ${err.message}\n`);
27
+ resolveP(1);
28
+ });
29
+ });
30
+ }
31
+ if (SUBCOMMAND === "install-service") {
32
+ const force = args.includes("--force");
33
+ const { install } = await import("./install-service.mjs");
34
+ return install({ force });
35
+ }
36
+ if (SUBCOMMAND === "uninstall-service") {
37
+ const { uninstall } = await import("./install-service.mjs");
38
+ return uninstall();
39
+ }
40
+ if (SUBCOMMAND === "--help" || SUBCOMMAND === "-h" || SUBCOMMAND === "help") {
41
+ process.stdout.write(
42
+ "Usage: cache-fix-proxy [subcommand] [args]\n\n" +
43
+ "Subcommands:\n" +
44
+ " (no subcommand) Spawn the proxy + launch claude with ANTHROPIC_BASE_URL set.\n" +
45
+ " Pass any claude args after optional --proxy-port / --proxy-upstream.\n" +
46
+ " server Run just the proxy in the foreground (for systemd/launchd ExecStart).\n" +
47
+ " install-service Install a systemd user service (Linux) or launchd agent (macOS).\n" +
48
+ " Pass --force to overwrite an existing config.\n" +
49
+ " uninstall-service Stop, disable, and remove the installed service.\n" +
50
+ " help Show this help.\n\n" +
51
+ "Wrapper-mode flags:\n" +
52
+ " --proxy-port <N> Port for the spawned proxy (default 9801)\n" +
53
+ " --proxy-upstream <URL> Upstream URL the proxy forwards to (default api.anthropic.com)\n" +
54
+ "\nEnvironment:\n" +
55
+ " CACHE_FIX_PROXY_PORT Port for the proxy server\n" +
56
+ " CACHE_FIX_PROXY_UPSTREAM Upstream URL\n" +
57
+ " CACHE_FIX_DEBUG=1 Verbose proxy logging\n" +
58
+ " CACHE_FIX_CLAUDE_CMD Override the `claude` command for the wrapper\n",
59
+ );
60
+ return 0;
61
+ }
62
+ return null;
63
+ }
64
+
65
+ const subcommandExit = await dispatch();
66
+ if (subcommandExit !== null) process.exit(subcommandExit);
67
+
68
+ // No subcommand matched → wrapper mode (back-compat with v3.0.x behavior).
12
69
  let proxyPort = 9801;
13
70
  let proxyUpstream = undefined;
14
71
  const claudeArgs = [];
@@ -0,0 +1,476 @@
1
+ // install-service / uninstall-service subcommands.
2
+ //
3
+ // Detects platform and installs an appropriate service definition for the
4
+ // cache-fix proxy:
5
+ // - linux → systemd user service at ~/.config/systemd/user/cache-fix-proxy.service
6
+ // - darwin → launchd agent at ~/Library/LaunchAgents/com.cnighswonger.cache-fix-proxy.plist
7
+ // - other → prints manual instructions and exits non-zero
8
+ //
9
+ // Pure helpers exported for tests; orchestration lives in main().
10
+
11
+ import { readFile, writeFile, mkdir, unlink, stat } from "node:fs/promises";
12
+ import { spawn } from "node:child_process";
13
+ import { fileURLToPath } from "node:url";
14
+ import { dirname, resolve, join } from "node:path";
15
+ import { homedir, platform } from "node:os";
16
+
17
+ const __dirname = dirname(fileURLToPath(import.meta.url));
18
+ const TEMPLATE_DIR = resolve(__dirname, "..", "templates");
19
+ const SERVER_PATH = resolve(__dirname, "..", "proxy", "server.mjs");
20
+
21
+ function getDefaults() {
22
+ return {
23
+ port: validatePort(process.env.CACHE_FIX_PROXY_PORT || "9801"),
24
+ upstream: process.env.CACHE_FIX_PROXY_UPSTREAM || "",
25
+ debug: process.env.CACHE_FIX_DEBUG || "",
26
+ workingDir: resolve(__dirname, ".."),
27
+ };
28
+ }
29
+
30
+ // Validate that a port string is a plain decimal integer in [1, 65535].
31
+ // We render this value into both a systemd Environment= line (safe) AND a
32
+ // /bin/sh -c command in the healthcheck oneshot — DANGEROUS without
33
+ // validation: shell metacharacters or quotes in a port string would let a
34
+ // hostile env var change the executed command. Throw on invalid input so
35
+ // callers report it cleanly via reportFsError.
36
+ function validatePort(raw) {
37
+ if (typeof raw !== "string" && typeof raw !== "number") {
38
+ throw new InvalidPortError(`port must be a number or numeric string, got ${typeof raw}`);
39
+ }
40
+ const s = String(raw).trim();
41
+ if (!/^\d+$/.test(s)) {
42
+ throw new InvalidPortError(`port must be a positive integer (got ${JSON.stringify(raw)})`);
43
+ }
44
+ const n = Number(s);
45
+ if (n < 1 || n > 65535) {
46
+ throw new InvalidPortError(`port must be in 1..65535 (got ${n})`);
47
+ }
48
+ return s;
49
+ }
50
+
51
+ class InvalidPortError extends Error {
52
+ constructor(message) {
53
+ super(message);
54
+ this.name = "InvalidPortError";
55
+ this.code = "EINVAL";
56
+ }
57
+ }
58
+
59
+ function getPaths(plat = platform()) {
60
+ if (plat === "linux") {
61
+ return {
62
+ kind: "systemd",
63
+ configDir: join(homedir(), ".config", "systemd", "user"),
64
+ configFile: "cache-fix-proxy.service",
65
+ label: "cache-fix-proxy",
66
+ // Healthcheck companion units (oneshot service + timer) for
67
+ // auto-recovery if the proxy is ever stopped from any cause:
68
+ // a crash, an external `systemctl stop`, an OOM, anything.
69
+ // The timer runs the service every 2 minutes; the oneshot does
70
+ // a curl /health probe and `systemctl --user start` if it fails.
71
+ healthcheckServiceFile: "cache-fix-proxy-healthcheck.service",
72
+ healthcheckTimerFile: "cache-fix-proxy-healthcheck.timer",
73
+ };
74
+ }
75
+ if (plat === "darwin") {
76
+ return {
77
+ kind: "launchd",
78
+ configDir: join(homedir(), "Library", "LaunchAgents"),
79
+ configFile: "com.cnighswonger.cache-fix-proxy.plist",
80
+ label: "com.cnighswonger.cache-fix-proxy",
81
+ logDir: join(homedir(), "Library", "Logs"),
82
+ // launchd's KeepAlive already auto-restarts the agent on any exit
83
+ // (clean or unclean), so a separate healthcheck isn't needed on macOS.
84
+ };
85
+ }
86
+ return { kind: "unsupported", platform: plat };
87
+ }
88
+
89
+ function renderSystemdTemplate(template, vars) {
90
+ const upstreamLine = vars.upstream
91
+ ? `Environment=CACHE_FIX_PROXY_UPSTREAM=${vars.upstream}`
92
+ : "";
93
+ const debugLine = vars.debug
94
+ ? `Environment=CACHE_FIX_DEBUG=${vars.debug}`
95
+ : "";
96
+ // Allow callers to wire a Requires= line (e.g. another service the proxy
97
+ // chains to). Empty string by default so the unit has no extra deps.
98
+ const requiresLine = vars.requires
99
+ ? `Requires=${vars.requires}\nAfter=${vars.requires}`
100
+ : "";
101
+ return template
102
+ .replaceAll("{{NODE}}", vars.node)
103
+ .replaceAll("{{SERVER_PATH}}", vars.serverPath)
104
+ .replaceAll("{{PORT}}", vars.port)
105
+ .replaceAll("{{UPSTREAM_LINE}}", upstreamLine)
106
+ .replaceAll("{{DEBUG_LINE}}", debugLine)
107
+ .replaceAll("{{REQUIRES_LINE}}", requiresLine)
108
+ .replaceAll("{{WORKING_DIR}}", vars.workingDir)
109
+ // Collapse triple newlines from empty optional lines down to single blank
110
+ .replace(/\n\n\n+/g, "\n\n");
111
+ }
112
+
113
+ function renderLaunchdTemplate(template, vars) {
114
+ const upstreamPlist = vars.upstream
115
+ ? ` <key>CACHE_FIX_PROXY_UPSTREAM</key>\n <string>${vars.upstream}</string>`
116
+ : "";
117
+ const debugPlist = vars.debug
118
+ ? ` <key>CACHE_FIX_DEBUG</key>\n <string>${vars.debug}</string>`
119
+ : "";
120
+ return template
121
+ .replaceAll("{{NODE}}", vars.node)
122
+ .replaceAll("{{SERVER_PATH}}", vars.serverPath)
123
+ .replaceAll("{{PORT}}", vars.port)
124
+ .replaceAll("{{UPSTREAM_PLIST}}", upstreamPlist)
125
+ .replaceAll("{{DEBUG_PLIST}}", debugPlist)
126
+ .replaceAll("{{WORKING_DIR}}", vars.workingDir)
127
+ .replaceAll("{{LOG_DIR}}", vars.logDir)
128
+ .replace(/\n\n+/g, "\n");
129
+ }
130
+
131
+ function renderHealthcheckServiceTemplate(template, vars) {
132
+ return template.replaceAll("{{PORT}}", vars.port);
133
+ }
134
+
135
+ function renderHealthcheckTimerTemplate(template) {
136
+ // No placeholders today, but keep the function for symmetry + future expansion.
137
+ return template;
138
+ }
139
+
140
+ async function fileExists(path) {
141
+ try {
142
+ await stat(path);
143
+ return true;
144
+ } catch {
145
+ return false;
146
+ }
147
+ }
148
+
149
+ function runCmd(cmd, args, opts = {}) {
150
+ return new Promise((resolveP) => {
151
+ const p = spawn(cmd, args, { stdio: "inherit", ...opts });
152
+ p.on("close", (code) => resolveP(code ?? 0));
153
+ p.on("error", () => resolveP(127));
154
+ });
155
+ }
156
+
157
+ async function installSystemd({ paths, defaults, force = false } = {}) {
158
+ paths = paths || getPaths("linux");
159
+ defaults = defaults || getDefaults();
160
+ const targetPath = join(paths.configDir, paths.configFile);
161
+ if ((await fileExists(targetPath)) && !force) {
162
+ return {
163
+ ok: false,
164
+ reason: "already-installed",
165
+ path: targetPath,
166
+ hint: "Re-run with --force to overwrite, or `cache-fix-proxy uninstall-service` first.",
167
+ };
168
+ }
169
+ const template = await readFile(
170
+ join(TEMPLATE_DIR, "cache-fix-proxy.service.template"),
171
+ "utf-8",
172
+ );
173
+ const rendered = renderSystemdTemplate(template, {
174
+ node: process.execPath,
175
+ serverPath: SERVER_PATH,
176
+ port: defaults.port,
177
+ upstream: defaults.upstream,
178
+ debug: defaults.debug,
179
+ workingDir: defaults.workingDir,
180
+ requires: "",
181
+ });
182
+ await mkdir(paths.configDir, { recursive: true });
183
+ await writeFile(targetPath, rendered);
184
+
185
+ // Healthcheck companion: oneshot service + timer. Auto-recovery from any
186
+ // proxy stop, including clean stops where Restart=on-failure does NOT fire
187
+ // (see incident analysis: 2026-04-25 01:46:53 UTC — proxy was stopped by
188
+ // an unidentified caller during the Anthropic outage and stayed down for
189
+ // 10 hours because no auto-recovery existed).
190
+ //
191
+ // If the healthcheck install fails (template missing, write error, etc.),
192
+ // roll back the main unit so the user isn't left in a half-installed
193
+ // state. Without rollback the proxy unit would exist but the auto-recovery
194
+ // story we just promised in the install message would be incomplete.
195
+ let healthcheckPaths;
196
+ try {
197
+ healthcheckPaths = await installSystemdHealthcheck({ paths, defaults, force });
198
+ } catch (err) {
199
+ try {
200
+ await unlink(targetPath);
201
+ } catch {
202
+ /* best-effort rollback */
203
+ }
204
+ throw err;
205
+ }
206
+
207
+ return {
208
+ ok: true,
209
+ path: targetPath,
210
+ healthcheck: healthcheckPaths,
211
+ };
212
+ }
213
+
214
+ async function installSystemdHealthcheck({ paths, defaults, force = false } = {}) {
215
+ paths = paths || getPaths("linux");
216
+ defaults = defaults || getDefaults();
217
+ const servicePath = join(paths.configDir, paths.healthcheckServiceFile);
218
+ const timerPath = join(paths.configDir, paths.healthcheckTimerFile);
219
+
220
+ // Symmetric existence check: if EITHER the service file OR the timer file
221
+ // already exists, refuse to overwrite without force. Catches both the
222
+ // "service exists, timer missing" and "timer exists, service missing"
223
+ // half-states — those are the artifacts most likely to need explicit
224
+ // operator review (e.g. a previous install crashed mid-write, or the
225
+ // operator has hand-edited one of the two).
226
+ const serviceExists = await fileExists(servicePath);
227
+ const timerExists = await fileExists(timerPath);
228
+ if ((serviceExists || timerExists) && !force) {
229
+ const which = serviceExists && timerExists
230
+ ? "both files"
231
+ : serviceExists
232
+ ? "service file"
233
+ : "timer file";
234
+ return {
235
+ installed: false,
236
+ reason: "already-installed",
237
+ servicePath,
238
+ timerPath,
239
+ hint: `${which} already present. Re-run with --force to overwrite, or \`cache-fix-proxy uninstall-service\` first.`,
240
+ };
241
+ }
242
+
243
+ const serviceTpl = await readFile(
244
+ join(TEMPLATE_DIR, "cache-fix-proxy-healthcheck.service.template"),
245
+ "utf-8",
246
+ );
247
+ const timerTpl = await readFile(
248
+ join(TEMPLATE_DIR, "cache-fix-proxy-healthcheck.timer.template"),
249
+ "utf-8",
250
+ );
251
+ await writeFile(servicePath, renderHealthcheckServiceTemplate(serviceTpl, { port: defaults.port }));
252
+ await writeFile(timerPath, renderHealthcheckTimerTemplate(timerTpl));
253
+ return { installed: true, servicePath, timerPath };
254
+ }
255
+
256
+ async function installLaunchd({ paths, defaults, force = false } = {}) {
257
+ paths = paths || getPaths("darwin");
258
+ defaults = defaults || getDefaults();
259
+ const targetPath = join(paths.configDir, paths.configFile);
260
+ if ((await fileExists(targetPath)) && !force) {
261
+ return {
262
+ ok: false,
263
+ reason: "already-installed",
264
+ path: targetPath,
265
+ hint: "Re-run with --force to overwrite, or `cache-fix-proxy uninstall-service` first.",
266
+ };
267
+ }
268
+ const template = await readFile(
269
+ join(TEMPLATE_DIR, "com.cnighswonger.cache-fix-proxy.plist.template"),
270
+ "utf-8",
271
+ );
272
+ const rendered = renderLaunchdTemplate(template, {
273
+ node: process.execPath,
274
+ serverPath: SERVER_PATH,
275
+ port: defaults.port,
276
+ upstream: defaults.upstream,
277
+ debug: defaults.debug,
278
+ workingDir: defaults.workingDir,
279
+ logDir: paths.logDir,
280
+ });
281
+ await mkdir(paths.configDir, { recursive: true });
282
+ await writeFile(targetPath, rendered);
283
+ return { ok: true, path: targetPath };
284
+ }
285
+
286
+ async function uninstallSystemd({ paths } = {}) {
287
+ paths = paths || getPaths("linux");
288
+ const targetPath = join(paths.configDir, paths.configFile);
289
+ if (!(await fileExists(targetPath))) {
290
+ return { ok: false, reason: "not-installed", path: targetPath };
291
+ }
292
+ await unlink(targetPath);
293
+ // Also remove the healthcheck companion if it exists. Best-effort —
294
+ // missing files are not an error here.
295
+ const healthcheckRemoved = await uninstallSystemdHealthcheck({ paths });
296
+ return { ok: true, path: targetPath, healthcheck: healthcheckRemoved };
297
+ }
298
+
299
+ async function uninstallSystemdHealthcheck({ paths } = {}) {
300
+ paths = paths || getPaths("linux");
301
+ const servicePath = join(paths.configDir, paths.healthcheckServiceFile);
302
+ const timerPath = join(paths.configDir, paths.healthcheckTimerFile);
303
+ let removed = 0;
304
+ for (const p of [timerPath, servicePath]) {
305
+ if (await fileExists(p)) {
306
+ try {
307
+ await unlink(p);
308
+ removed++;
309
+ } catch {
310
+ /* best-effort */
311
+ }
312
+ }
313
+ }
314
+ return { removed, servicePath, timerPath };
315
+ }
316
+
317
+ async function uninstallLaunchd({ paths } = {}) {
318
+ paths = paths || getPaths("darwin");
319
+ const targetPath = join(paths.configDir, paths.configFile);
320
+ if (!(await fileExists(targetPath))) {
321
+ return { ok: false, reason: "not-installed", path: targetPath };
322
+ }
323
+ await unlink(targetPath);
324
+ return { ok: true, path: targetPath };
325
+ }
326
+
327
+ async function install({ force = false } = {}) {
328
+ const paths = getPaths();
329
+ if (paths.kind === "unsupported") {
330
+ process.stderr.write(
331
+ `[install-service] Unsupported platform: ${paths.platform}\n` +
332
+ `Manual install: run \`node ${SERVER_PATH}\` under your platform's service manager.\n`,
333
+ );
334
+ return 1;
335
+ }
336
+ if (paths.kind === "systemd") {
337
+ let r;
338
+ try {
339
+ r = await installSystemd({ paths, force });
340
+ } catch (err) {
341
+ return reportFsError("install-service", err);
342
+ }
343
+ if (!r.ok) {
344
+ process.stderr.write(`[install-service] ${r.reason}: ${r.path}\n`);
345
+ if (r.hint) process.stderr.write(` ${r.hint}\n`);
346
+ return 1;
347
+ }
348
+ const hcLines =
349
+ r.healthcheck?.installed
350
+ ? `Wrote healthcheck companion: ${r.healthcheck.servicePath}\n` +
351
+ `Wrote healthcheck timer: ${r.healthcheck.timerPath}\n\n`
352
+ : r.healthcheck?.reason === "already-installed"
353
+ ? `Healthcheck companion already installed (use --force to overwrite).\n\n`
354
+ : "";
355
+ process.stdout.write(
356
+ `Wrote systemd unit: ${r.path}\n` +
357
+ hcLines +
358
+ `Next steps:\n` +
359
+ ` systemctl --user daemon-reload\n` +
360
+ ` systemctl --user enable --now cache-fix-proxy\n` +
361
+ ` systemctl --user enable --now cache-fix-proxy-healthcheck.timer # auto-recovery if proxy is ever stopped\n` +
362
+ ` loginctl enable-linger ${process.env.USER || "<your-user>"} # optional: start on boot vs login\n`,
363
+ );
364
+ return 0;
365
+ }
366
+ if (paths.kind === "launchd") {
367
+ let r;
368
+ try {
369
+ r = await installLaunchd({ paths, force });
370
+ } catch (err) {
371
+ return reportFsError("install-service", err);
372
+ }
373
+ if (!r.ok) {
374
+ process.stderr.write(`[install-service] ${r.reason}: ${r.path}\n`);
375
+ if (r.hint) process.stderr.write(` ${r.hint}\n`);
376
+ return 1;
377
+ }
378
+ process.stdout.write(
379
+ `Wrote launchd plist: ${r.path}\n\n` +
380
+ `Next steps:\n` +
381
+ ` launchctl bootstrap gui/$(id -u) ${r.path}\n` +
382
+ ` launchctl enable gui/$(id -u)/com.cnighswonger.cache-fix-proxy\n` +
383
+ ` launchctl kickstart gui/$(id -u)/com.cnighswonger.cache-fix-proxy\n`,
384
+ );
385
+ return 0;
386
+ }
387
+ return 1;
388
+ }
389
+
390
+ // Translate raw fs / validation errors into operator-friendly one-liners.
391
+ // Returns the exit code so callers can pass it straight back.
392
+ function reportFsError(prefix, err) {
393
+ const code = err?.code ?? "";
394
+ let hint = "";
395
+ if (err?.name === "InvalidPortError") hint = err.message;
396
+ else if (code === "ENOENT") hint = "file or directory not found";
397
+ else if (code === "EACCES" || code === "EPERM") hint = "permission denied";
398
+ else if (code === "ENOSPC") hint = "no space left on device";
399
+ else hint = err?.message || String(err);
400
+ process.stderr.write(`[${prefix}] ${hint}${err?.path ? `: ${err.path}` : ""}\n`);
401
+ return 1;
402
+ }
403
+
404
+ async function uninstall() {
405
+ const paths = getPaths();
406
+ if (paths.kind === "unsupported") {
407
+ process.stderr.write(`[uninstall-service] Unsupported platform: ${paths.platform}\n`);
408
+ return 1;
409
+ }
410
+ if (paths.kind === "systemd") {
411
+ // Best-effort stop + disable for the healthcheck companion FIRST so it
412
+ // doesn't immediately restart the proxy we're about to stop.
413
+ await runCmd("systemctl", ["--user", "stop", "cache-fix-proxy-healthcheck.timer"]);
414
+ await runCmd("systemctl", ["--user", "disable", "cache-fix-proxy-healthcheck.timer"]);
415
+ // Then stop + disable the main service.
416
+ await runCmd("systemctl", ["--user", "stop", "cache-fix-proxy"]);
417
+ await runCmd("systemctl", ["--user", "disable", "cache-fix-proxy"]);
418
+ let r;
419
+ try {
420
+ r = await uninstallSystemd({ paths });
421
+ } catch (err) {
422
+ return reportFsError("uninstall-service", err);
423
+ }
424
+ if (!r.ok) {
425
+ process.stderr.write(`[uninstall-service] ${r.reason}: ${r.path}\n`);
426
+ return 1;
427
+ }
428
+ await runCmd("systemctl", ["--user", "daemon-reload"]);
429
+ const hcMsg =
430
+ r.healthcheck?.removed > 0
431
+ ? ` (+ ${r.healthcheck.removed} healthcheck file${r.healthcheck.removed === 1 ? "" : "s"})`
432
+ : "";
433
+ process.stdout.write(`Removed: ${r.path}${hcMsg}\n`);
434
+ return 0;
435
+ }
436
+ if (paths.kind === "launchd") {
437
+ const targetPath = join(paths.configDir, paths.configFile);
438
+ await runCmd("launchctl", ["bootout", `gui/${process.getuid()}`, targetPath]);
439
+ let r;
440
+ try {
441
+ r = await uninstallLaunchd({ paths });
442
+ } catch (err) {
443
+ return reportFsError("uninstall-service", err);
444
+ }
445
+ if (!r.ok) {
446
+ process.stderr.write(`[uninstall-service] ${r.reason}: ${r.path}\n`);
447
+ return 1;
448
+ }
449
+ process.stdout.write(`Removed: ${r.path}\n`);
450
+ return 0;
451
+ }
452
+ return 1;
453
+ }
454
+
455
+ export {
456
+ // Pure helpers (test surface)
457
+ renderSystemdTemplate,
458
+ renderLaunchdTemplate,
459
+ renderHealthcheckServiceTemplate,
460
+ renderHealthcheckTimerTemplate,
461
+ getPaths,
462
+ getDefaults,
463
+ validatePort,
464
+ InvalidPortError,
465
+ installSystemd,
466
+ installSystemdHealthcheck,
467
+ installLaunchd,
468
+ uninstallSystemd,
469
+ uninstallSystemdHealthcheck,
470
+ uninstallLaunchd,
471
+ // Orchestration
472
+ install,
473
+ uninstall,
474
+ TEMPLATE_DIR,
475
+ SERVER_PATH,
476
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-cache-fix",
3
- "version": "3.0.5",
3
+ "version": "3.1.1",
4
4
  "description": "Cache optimization proxy and interceptor for Claude Code. Fixes prompt cache bugs, stabilizes prefix, reduces quota burn.",
5
5
  "type": "module",
6
6
  "exports": "./preload.mjs",
@@ -14,7 +14,8 @@
14
14
  "tools/",
15
15
  "claude-fixed.bat",
16
16
  "proxy/",
17
- "bin/"
17
+ "bin/",
18
+ "templates/"
18
19
  ],
19
20
  "engines": {
20
21
  "node": ">=18"
@@ -24,6 +24,8 @@ function countUserCacheControlMarkers(body) {
24
24
  return n;
25
25
  }
26
26
 
27
+ export { stripCacheControlMarkers, countUserCacheControlMarkers };
28
+
27
29
  export default {
28
30
  name: "cache-control-normalize",
29
31
  description: "Strip scattered cache_control markers from user messages and apply canonical placement",