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 +1 -1
- package/README.md +27 -21
- package/README.zh.md +1 -1
- package/bin/claude-via-proxy.mjs +57 -0
- package/bin/install-service.mjs +476 -0
- package/package.json +3 -2
- package/proxy/extensions/cache-control-normalize.mjs +2 -0
- package/proxy/extensions/content-strip.mjs +89 -0
- package/proxy/extensions/deferred-tools-restore.mjs +361 -0
- package/proxy/extensions/fingerprint-strip.mjs +2 -0
- package/proxy/extensions/fresh-session-sort.mjs +2 -0
- package/proxy/extensions/identity-normalization.mjs +2 -0
- package/proxy/extensions/image-strip.mjs +83 -0
- package/proxy/extensions/output-efficiency-rewrite.mjs +64 -0
- package/proxy/extensions/prefix-diff.mjs +277 -0
- package/proxy/extensions/smoosh-split.mjs +68 -0
- package/proxy/extensions/sort-stabilization.mjs +2 -0
- package/proxy/extensions/tool-input-normalize.mjs +73 -0
- package/proxy/extensions/ttl-management.mjs +2 -0
- package/proxy/extensions/usage-log.mjs +46 -0
- package/proxy/extensions.json +3 -0
- package/templates/cache-fix-proxy-healthcheck.service.template +7 -0
- package/templates/cache-fix-proxy-healthcheck.timer.template +14 -0
- package/templates/cache-fix-proxy.service.template +17 -0
- package/templates/com.cnighswonger.cache-fix-proxy.plist.template +33 -0
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/` 아래 로컬 파일에 기록됩니다.
|
|
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
|
-
**
|
|
48
|
+
**Recommended (Linux/macOS) — `install-service` subcommand:**
|
|
49
49
|
|
|
50
|
-
|
|
50
|
+
```bash
|
|
51
|
+
cache-fix-proxy install-service
|
|
52
|
+
```
|
|
51
53
|
|
|
52
|
-
|
|
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
|
-
|
|
58
|
-
|
|
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
|
-
|
|
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
|
-
|
|
73
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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/`
|
|
140
|
+
**它不做什么:** 代理或拦截器不会发起网络调用。所有遥测数据写入 `~/.claude/` 下的本地文件。数据不会离开你的机器。
|
|
141
141
|
|
|
142
142
|
**供应链:** 代理模式:7 个小型扩展模块在 `proxy/extensions/` 中(每个不到 200 行)。预加载模式:单个未压缩文件(`preload.mjs`,约 1,700 行)。一个开发依赖(`zod`,仅用于测试中的模式验证)。安装前请审查代码。npm 出处(provenance)将每个发布版本链接到其源代码提交。
|
|
143
143
|
|
package/bin/claude-via-proxy.mjs
CHANGED
|
@@ -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.
|
|
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",
|