pi-proxy-guard 0.6.0 → 0.6.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.md +26 -11
- package/extensions/proxy-guard.ts +132 -31
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@ Auto-recover [pi](https://github.com/earendil-works/pi) sessions when the proxy
|
|
|
4
4
|
|
|
5
5
|
When all provider retries fail (`Retry failed after N attempts` / `Stream ended without finish_reason`), pi pauses the session. This extension:
|
|
6
6
|
|
|
7
|
-
1. **Checks connectivity
|
|
7
|
+
1. **Checks connectivity**, two tiers: the watchdog uses a cheap ping (`generate_204` through the same `https_proxy` env pi uses); at settle/recheck time it does a **deep probe** — API host reachability (`ctx.model.baseUrl`, any HTTP response counts) **plus a sustained-transfer check** (`STREAM_MIN_BYTES` from `STREAM_URL`) — the signal a 0-byte ping can't see: whether the chain can actually hold a streaming connection. 'Down' requires `PROBE_ATTEMPTS` consecutive failures.
|
|
8
8
|
2. **Auto-continues** the run if the proxy is fine (transient stream cut).
|
|
9
9
|
3. **Repairs the proxy** if it's down — runs a macOS Shortcuts shortcut, falls back to `shadowrocket://` URL schemes — then continues once connectivity is verified.
|
|
10
10
|
4. **Watchdog**: if pi is still paused, it keeps watching; once the proxy is verified healthy again it sends `continue` itself. It also repairs the proxy while pi is mid-retry, so many incidents never reach the pause at all.
|
|
@@ -38,18 +38,27 @@ pi install ~/Developer/pi-proxy-guard
|
|
|
38
38
|
|
|
39
39
|
Restart pi. `/proxyguard check|restart|status` verifies it's live; logs go to `~/.pi/agent/proxy-guard.log`.
|
|
40
40
|
|
|
41
|
-
## Setup:
|
|
41
|
+
## Setup: repairing Shadowrocket on macOS
|
|
42
42
|
|
|
43
|
-
|
|
43
|
+
Zero-setup (verified on the Apple-Silicon iOS-app runtime): the extension runs
|
|
44
44
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
3
|
|
48
|
-
|
|
45
|
+
```sh
|
|
46
|
+
open -g "shadowrocket://disconnect?autoclose=true" # tunnel drops
|
|
47
|
+
sleep 3
|
|
48
|
+
open -g "shadowrocket://connect?autoclose=true" # fresh tunnel
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
`-g` keeps Shadowrocket in the background; `autoclose=true` lets it quit itself. If you have **always-on** enabled, the tunnel may auto-reconnect before the explicit connect — either way you end up on a fresh tunnel, which is the point (the dead stream is already dead; repair makes the *next* request land on a rebuilt chain).
|
|
52
|
+
|
|
53
|
+
### Shortcut path (optional)
|
|
54
|
+
|
|
55
|
+
`PI_PROXY_GUARD_SHORTCUT` (default name **Reconnect Shadowrocket**) runs `shortcuts run <name>` first, scheme fallback after. **Verified caveat**: on macOS, `shortcuts run` does *not* fire iOS-app SiriKit intents — a shortcut wrapping Shadowrocket's `StopVPNIntent`/`StartVPNIntent` is a silent no-op via CLI (it only fires from the Shortcuts.app GUI). So on macOS the shortcut path only helps if it wraps shell-able actions (e.g. `Run Shell Script` driving another client). Set `PI_PROXY_GUARD_SHORTCUT=""` to skip it entirely.
|
|
56
|
+
|
|
57
|
+
Node switching is intentionally left to the proxy client (chain-proxy friendly). To make the *repair* more effective, you can chain actions into the URL scheme too — e.g. `shadowrocket://select?s=<node>` before `connect` selects a different entry node.
|
|
49
58
|
|
|
50
|
-
|
|
59
|
+
### Verification note
|
|
51
60
|
|
|
52
|
-
|
|
61
|
+
`scutil --nc show/list` reports the NE manager's *intent* state and lags reality by several seconds — during a real disconnect it still says `Connected`. The extension therefore verifies repair with real HTTP probes through the proxy, never scutil.
|
|
53
62
|
|
|
54
63
|
## Tuning pi's own retries
|
|
55
64
|
|
|
@@ -64,9 +73,15 @@ Node switching is intentionally left to the proxy client (chain-proxy friendly).
|
|
|
64
73
|
| Var | Default | Meaning |
|
|
65
74
|
|---|---|---|
|
|
66
75
|
| `PI_PROXY_GUARD` | `1` | `0` disables |
|
|
67
|
-
| `PI_PROXY_GUARD_URL` | `https://www.google.com/generate_204` |
|
|
76
|
+
| `PI_PROXY_GUARD_URL` | `https://www.google.com/generate_204` | cheap ping (watchdog ticks) |
|
|
77
|
+
| `PI_PROXY_GUARD_API_URL` | `ctx.model.baseUrl` | deep-probe target; defaults to the active model's API host — any HTTP response counts as reachable |
|
|
78
|
+
| `PI_PROXY_GUARD_STREAM_URL` | `https://speed.cloudflare.com/__down?bytes=65536` | sustained-transfer probe used in deep checks |
|
|
79
|
+
| `PI_PROXY_GUARD_STREAM_MIN_BYTES` | `60000` | min bytes the transfer must deliver; `0` disables it |
|
|
80
|
+
| `PI_PROXY_GUARD_VPN_SERVICE` | _unset_ | `scutil --nc` service name — tunnel-down short-circuits straight to repair |
|
|
68
81
|
| `PI_PROXY_GUARD_PROXY` | _(env)_ | explicit `-x` proxy for the check |
|
|
69
|
-
| `PI_PROXY_GUARD_TIMEOUT_MS` | `
|
|
82
|
+
| `PI_PROXY_GUARD_TIMEOUT_MS` | `20000` | per-attempt check timeout |
|
|
83
|
+
| `PI_PROXY_GUARD_PROBE_ATTEMPTS` | `2` | consecutive failures required to declare "down" — tolerates latency spikes without mis-firing a repair |
|
|
84
|
+
| `PI_PROXY_GUARD_PROBE_GAP_MS` | `1500` | gap between probe attempts |
|
|
70
85
|
| `PI_PROXY_GUARD_SHORTCUT` | `Reconnect Shadowrocket` | `""` disables |
|
|
71
86
|
| `PI_PROXY_GUARD_SCHEME` | `1` | `0` disables `shadowrocket://` fallback |
|
|
72
87
|
| `PI_PROXY_GUARD_ESCALATE_AFTER` | `2` | consecutive errors → repair despite OK ping |
|
|
@@ -32,13 +32,32 @@
|
|
|
32
32
|
* Env knobs (defaults):
|
|
33
33
|
* PI_PROXY_GUARD=0 disable
|
|
34
34
|
* PI_PROXY_GUARD_URL https://www.google.com/generate_204
|
|
35
|
+
* (cheap ping used by the watchdog)
|
|
36
|
+
* PI_PROXY_GUARD_API_URL (unset = use ctx.model.baseUrl)
|
|
37
|
+
* PI_PROXY_GUARD_STREAM_URL https://speed.cloudflare.com/__down?bytes=65536
|
|
38
|
+
* PI_PROXY_GUARD_STREAM_MIN_BYTES 60000 (0 disables transfer probe)
|
|
39
|
+
* PI_PROXY_GUARD_VPN_SERVICE "" (scutil --nc fast-path name)
|
|
35
40
|
* PI_PROXY_GUARD_PROXY (unset = inherit env proxy vars)
|
|
36
|
-
* PI_PROXY_GUARD_TIMEOUT_MS
|
|
41
|
+
* PI_PROXY_GUARD_TIMEOUT_MS 20000
|
|
42
|
+
* PI_PROXY_GUARD_PROBE_ATTEMPTS 2 (down requires N consecutive fails)
|
|
43
|
+
* PI_PROXY_GUARD_PROBE_GAP_MS 1500 (gap between probe attempts)
|
|
37
44
|
* PI_PROXY_GUARD_SHORTCUT "Reconnect Shadowrocket" ("" disables)
|
|
38
45
|
* PI_PROXY_GUARD_SHORTCUT_TIMEOUT_MS 120000
|
|
46
|
+
* NOTE: `shortcuts run` on macOS does NOT
|
|
47
|
+
* fire iOS-app SiriKit intents (verified):
|
|
48
|
+
* a shortcut wrapping Shadowrocket's
|
|
49
|
+
* StopVPNIntent is a no-op via CLI, works
|
|
50
|
+
* only from Shortcuts.app GUI. Only useful
|
|
51
|
+
* for shortcuts with shell-able actions.
|
|
39
52
|
* PI_PROXY_GUARD_SCHEME "1" (shadowrocket:// fallback)
|
|
40
|
-
* PI_PROXY_GUARD_SCHEME_STOP shadowrocket://
|
|
41
|
-
* PI_PROXY_GUARD_SCHEME_START shadowrocket://
|
|
53
|
+
* PI_PROXY_GUARD_SCHEME_STOP shadowrocket://disconnect?autoclose=true
|
|
54
|
+
* PI_PROXY_GUARD_SCHEME_START shadowrocket://connect?autoclose=true
|
|
55
|
+
* Verified working on macOS (iOS-app
|
|
56
|
+
* runtime): `open -g` delivers the URL
|
|
57
|
+
* without foregrounding; tunnel drops and
|
|
58
|
+
* comes back. If Shadowrocket "always-on"
|
|
59
|
+
* is enabled it may auto-reconnect before
|
|
60
|
+
* our explicit connect — harmless either way.
|
|
42
61
|
* PI_PROXY_GUARD_ESCALATE_AFTER 2 (consecutive errors w/ OK check)
|
|
43
62
|
* PI_PROXY_GUARD_PAUSED_REPAIRS 3 (active repair cap while paused)
|
|
44
63
|
* PI_PROXY_GUARD_REPAIR_COOLDOWN_MS 90000
|
|
@@ -68,13 +87,21 @@ import { setTimeout as sleep } from "node:timers/promises";
|
|
|
68
87
|
|
|
69
88
|
const DISABLED = process.env.PI_PROXY_GUARD === "0";
|
|
70
89
|
const CHECK_URL = process.env.PI_PROXY_GUARD_URL ?? "https://www.google.com/generate_204";
|
|
90
|
+
const API_URL_OVERRIDE = process.env.PI_PROXY_GUARD_API_URL;
|
|
91
|
+
const STREAM_URL = process.env.PI_PROXY_GUARD_STREAM_URL ?? "https://speed.cloudflare.com/__down?bytes=65536";
|
|
92
|
+
const STREAM_MIN_BYTES = Number(process.env.PI_PROXY_GUARD_STREAM_MIN_BYTES ?? 60_000);
|
|
93
|
+
const VPN_SERVICE = process.env.PI_PROXY_GUARD_VPN_SERVICE ?? "";
|
|
71
94
|
const CHECK_PROXY = process.env.PI_PROXY_GUARD_PROXY;
|
|
72
|
-
const CHECK_TIMEOUT_MS = Number(process.env.PI_PROXY_GUARD_TIMEOUT_MS ??
|
|
95
|
+
const CHECK_TIMEOUT_MS = Number(process.env.PI_PROXY_GUARD_TIMEOUT_MS ?? 20_000);
|
|
96
|
+
const PROBE_ATTEMPTS = Number(process.env.PI_PROXY_GUARD_PROBE_ATTEMPTS ?? 2);
|
|
97
|
+
const PROBE_GAP_MS = Number(process.env.PI_PROXY_GUARD_PROBE_GAP_MS ?? 1_500);
|
|
73
98
|
const SHORTCUT = process.env.PI_PROXY_GUARD_SHORTCUT ?? "Reconnect Shadowrocket";
|
|
74
99
|
const SHORTCUT_TIMEOUT_MS = Number(process.env.PI_PROXY_GUARD_SHORTCUT_TIMEOUT_MS ?? 120_000);
|
|
75
100
|
const USE_SCHEME = process.env.PI_PROXY_GUARD_SCHEME !== "0";
|
|
76
|
-
|
|
77
|
-
|
|
101
|
+
// Verified against Shadowrocket's docs: connect/disconnect (not start/stop).
|
|
102
|
+
// autoclose=true lets the app quit itself after handling the action.
|
|
103
|
+
const SCHEME_STOP = process.env.PI_PROXY_GUARD_SCHEME_STOP ?? "shadowrocket://disconnect?autoclose=true";
|
|
104
|
+
const SCHEME_START = process.env.PI_PROXY_GUARD_SCHEME_START ?? "shadowrocket://connect?autoclose=true";
|
|
78
105
|
const ESCALATE_AFTER = Number(process.env.PI_PROXY_GUARD_ESCALATE_AFTER ?? 2);
|
|
79
106
|
const PAUSED_REPAIR_MAX = Number(process.env.PI_PROXY_GUARD_PAUSED_REPAIRS ?? 3);
|
|
80
107
|
const REPAIR_COOLDOWN_MS = Number(process.env.PI_PROXY_GUARD_REPAIR_COOLDOWN_MS ?? 90_000);
|
|
@@ -128,27 +155,68 @@ interface CheckResult {
|
|
|
128
155
|
error?: string;
|
|
129
156
|
}
|
|
130
157
|
|
|
131
|
-
function
|
|
158
|
+
function execCmd(cmd: string, args: string[], timeoutMs: number): Promise<{ ok: boolean; output?: string; error?: string }> {
|
|
159
|
+
return new Promise((resolve) => {
|
|
160
|
+
execFile(cmd, args, { timeout: timeoutMs }, (err, stdout, stderr) => {
|
|
161
|
+
resolve(err
|
|
162
|
+
? { ok: false, output: stdout, error: ((stderr || "") + err.message).split("\n")[0].trim() }
|
|
163
|
+
: { ok: true, output: stdout });
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Reachability probe: any HTTP response (even 404/401/502) means the
|
|
169
|
+
* transport path to the host is alive — that's what we measure, not
|
|
170
|
+
* whether the endpoint "succeeds". */
|
|
171
|
+
function httpProbe(url: string): Promise<CheckResult> {
|
|
132
172
|
return new Promise((resolve) => {
|
|
133
173
|
const args = ["-sS", "-o", "/dev/null", "-w", "%{http_code}", "--max-time", String(Math.ceil(CHECK_TIMEOUT_MS / 1000))];
|
|
134
174
|
if (CHECK_PROXY) args.push("-x", CHECK_PROXY);
|
|
135
|
-
args.push(
|
|
175
|
+
args.push(url);
|
|
136
176
|
execFile("curl", args, { timeout: CHECK_TIMEOUT_MS + 2_000 }, (err, stdout) => {
|
|
137
177
|
const status = Number.parseInt((stdout ?? "").trim(), 10);
|
|
138
|
-
if (!err && status
|
|
178
|
+
if (!err && status > 0) resolve({ ok: true, status });
|
|
139
179
|
else resolve({ ok: false, status: Number.isFinite(status) ? status : undefined, error: (err?.message ?? "").split("\n")[0] });
|
|
140
180
|
});
|
|
141
181
|
});
|
|
142
182
|
}
|
|
143
183
|
|
|
144
|
-
|
|
184
|
+
/** Sustained-transfer probe: downloads STREAM_URL and requires the body to
|
|
185
|
+
* actually arrive (>= STREAM_MIN_BYTES). A half-dead chain that passes tiny
|
|
186
|
+
* pings but cuts long-lived streams gets caught here. */
|
|
187
|
+
function streamProbe(url: string): Promise<CheckResult> {
|
|
145
188
|
return new Promise((resolve) => {
|
|
146
|
-
|
|
147
|
-
|
|
189
|
+
const args = ["-sS", "-o", "/dev/null", "-w", "%{http_code} %{size_download}", "--max-time", String(Math.ceil(CHECK_TIMEOUT_MS / 1000))];
|
|
190
|
+
if (CHECK_PROXY) args.push("-x", CHECK_PROXY);
|
|
191
|
+
args.push(url);
|
|
192
|
+
execFile("curl", args, { timeout: CHECK_TIMEOUT_MS + 2_000 }, (err, stdout) => {
|
|
193
|
+
const [statusRaw, bytesRaw] = (stdout ?? "").trim().split(/\s+/);
|
|
194
|
+
const status = Number.parseInt(statusRaw, 10);
|
|
195
|
+
const bytes = Number.parseInt(bytesRaw, 10);
|
|
196
|
+
if (!err && status > 0 && bytes >= STREAM_MIN_BYTES) resolve({ ok: true, status });
|
|
197
|
+
else
|
|
198
|
+
resolve({
|
|
199
|
+
ok: false,
|
|
200
|
+
status: Number.isFinite(status) ? status : undefined,
|
|
201
|
+
error: err ? err.message.split("\n")[0] : `stream cut: got ${bytes}B < ${STREAM_MIN_BYTES}B`,
|
|
202
|
+
});
|
|
148
203
|
});
|
|
149
204
|
});
|
|
150
205
|
}
|
|
151
206
|
|
|
207
|
+
/** Optional fast path: the VPN tunnel state itself, zero network traffic. */
|
|
208
|
+
async function tunnelUp(): Promise<boolean | undefined> {
|
|
209
|
+
if (!VPN_SERVICE) return undefined;
|
|
210
|
+
const r = await execCmd("scutil", ["--nc", "show", VPN_SERVICE], 5_000);
|
|
211
|
+
if (!r.ok || r.output === undefined) return undefined;
|
|
212
|
+
return /\bConnected\b/.test(r.output) ? true : /\bDisconnected\b/.test(r.output) ? false : undefined;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Back-compat shim: reachability via the configured CHECK_URL. */
|
|
216
|
+
function checkProxy(): Promise<CheckResult> {
|
|
217
|
+
return httpProbe(CHECK_URL);
|
|
218
|
+
}
|
|
219
|
+
|
|
152
220
|
export default function (pi: ExtensionAPI) {
|
|
153
221
|
if (DISABLED) return;
|
|
154
222
|
|
|
@@ -237,14 +305,39 @@ export default function (pi: ExtensionAPI) {
|
|
|
237
305
|
}
|
|
238
306
|
}
|
|
239
307
|
|
|
240
|
-
/** checkProxy + episode bookkeeping: an OK probe resets the repair counter.
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
308
|
+
/** checkProxy + episode bookkeeping: an OK probe resets the repair counter.
|
|
309
|
+
* A single failure never means "down" on a wobbly chain proxy — we
|
|
310
|
+
* require PROBE_ATTEMPTS consecutive failures before declaring dead.
|
|
311
|
+
* This prevents latency spikes from triggering needless VPN bounces
|
|
312
|
+
* (which would kill the very streams we're trying to protect).
|
|
313
|
+
*
|
|
314
|
+
* deep=true (settle/recheck): probes the actual API host (ctx.model.baseUrl
|
|
315
|
+
* or PI_PROXY_GUARD_API_URL) and adds a sustained-transfer check — the
|
|
316
|
+
* "can it hold an SSE stream" signal a 0-byte ping can't see.
|
|
317
|
+
* deep=false (watchdog): cheap generate_204 ping only. */
|
|
318
|
+
async function probe(ctx?: ExtensionContext, deep = false): Promise<CheckResult> {
|
|
319
|
+
const vpn = await tunnelUp();
|
|
320
|
+
if (vpn === false) return { ok: false, error: `VPN tunnel "${VPN_SERVICE}" disconnected` };
|
|
321
|
+
|
|
322
|
+
const target = (deep ? (API_URL_OVERRIDE ?? ctx?.model?.baseUrl) : undefined) ?? CHECK_URL;
|
|
323
|
+
let last: CheckResult = { ok: false };
|
|
324
|
+
for (let attempt = 1; attempt <= PROBE_ATTEMPTS; attempt++) {
|
|
325
|
+
last = await httpProbe(target);
|
|
326
|
+
if (last.ok && deep && STREAM_MIN_BYTES > 0) {
|
|
327
|
+
const stream = await streamProbe(STREAM_URL);
|
|
328
|
+
if (!stream.ok) last = stream;
|
|
329
|
+
}
|
|
330
|
+
if (last.ok) {
|
|
331
|
+
failedRepairs = 0;
|
|
332
|
+
gaveUpNotified = false;
|
|
333
|
+
return last;
|
|
334
|
+
}
|
|
335
|
+
if (attempt < PROBE_ATTEMPTS) {
|
|
336
|
+
log(`probe attempt ${attempt}/${PROBE_ATTEMPTS} failed (${last.error ?? `HTTP ${last.status}`}); retrying`);
|
|
337
|
+
await sleep(PROBE_GAP_MS);
|
|
338
|
+
}
|
|
246
339
|
}
|
|
247
|
-
return
|
|
340
|
+
return last;
|
|
248
341
|
}
|
|
249
342
|
|
|
250
343
|
/** True while we may still launch an active repair this down-episode. */
|
|
@@ -262,7 +355,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
262
355
|
try {
|
|
263
356
|
const recheck = async (): Promise<boolean> => {
|
|
264
357
|
await sleep(RECHECK_DELAY_MS);
|
|
265
|
-
const check = await
|
|
358
|
+
const check = await probe(ctx, true); // deep: verify real capacity before unpausing
|
|
266
359
|
if (!check.ok) log(`still down: ${check.error ?? `HTTP ${check.status}`}`);
|
|
267
360
|
return check.ok;
|
|
268
361
|
};
|
|
@@ -279,13 +372,14 @@ export default function (pi: ExtensionAPI) {
|
|
|
279
372
|
const viaScheme = async (): Promise<boolean> => {
|
|
280
373
|
if (!USE_SCHEME) return false;
|
|
281
374
|
notify(ctx, "Bouncing Shadowrocket via URL scheme…", "warning");
|
|
282
|
-
|
|
375
|
+
// `open -g`: background — do NOT activate/foreground Shadowrocket.
|
|
376
|
+
const stop = await execCmd("open", ["-g", SCHEME_STOP], 15_000);
|
|
283
377
|
if (!stop.ok) {
|
|
284
378
|
log(`scheme stop failed: ${stop.error}`);
|
|
285
379
|
return false;
|
|
286
380
|
}
|
|
287
381
|
await sleep(3_000);
|
|
288
|
-
const start = await execCmd("open", [SCHEME_START], 15_000);
|
|
382
|
+
const start = await execCmd("open", ["-g", SCHEME_START], 15_000);
|
|
289
383
|
if (!start.ok) {
|
|
290
384
|
log(`scheme start failed: ${start.error}`);
|
|
291
385
|
return false;
|
|
@@ -315,10 +409,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
315
409
|
// Only spend effort when pi actually needs the connection:
|
|
316
410
|
// a live run (internal retries benefit) or a paused-by-us session.
|
|
317
411
|
if (!runActive && !pausedByUs) return;
|
|
318
|
-
if (budgetLeft() <= 0) return;
|
|
319
412
|
ticking = true;
|
|
320
413
|
try {
|
|
321
|
-
const check = await probe();
|
|
414
|
+
const check = await probe(sessionCtx, false); // cheap ping for the periodic watch
|
|
322
415
|
if (check.ok) {
|
|
323
416
|
// Recovered while paused: resume.
|
|
324
417
|
if (pausedByUs && !runActive) {
|
|
@@ -341,6 +434,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
341
434
|
}
|
|
342
435
|
return;
|
|
343
436
|
}
|
|
437
|
+
// Repairing the network is free — only resumes/continues spend
|
|
438
|
+
// provider requests, so the budget gates those, not the repair.
|
|
344
439
|
if (await repairProxy(sessionCtx)) {
|
|
345
440
|
if (pausedByUs && !runActive && budgetLeft() > 0) {
|
|
346
441
|
chargeBudget();
|
|
@@ -410,13 +505,18 @@ export default function (pi: ExtensionAPI) {
|
|
|
410
505
|
`settle error #${consecutiveErrors}; canContinue=${event.context.canContinue}; ` +
|
|
411
506
|
`budget=${budgetLeft()}/${MAX_REPAIRS}; pausedByUs=${pausedByUs}`,
|
|
412
507
|
);
|
|
413
|
-
|
|
508
|
+
const check = await probe(ctx, true); // deep: API host + sustained transfer
|
|
509
|
+
const canContinue = budgetLeft() > 0;
|
|
510
|
+
if (!canContinue) {
|
|
511
|
+
// Budget is about *provider requests*, not connectivity: still
|
|
512
|
+
// repair a dead proxy so the user's manual take-over lands on a
|
|
513
|
+
// working network — then stay paused without continuing.
|
|
514
|
+
if (!check.ok) await repairProxy(ctx);
|
|
414
515
|
notify(ctx, `Auto-continue budget exhausted (${MAX_REPAIRS}/${Math.round(WINDOW_MS / 60_000)}min). Staying paused — take over manually.`, "warning");
|
|
415
516
|
systemNotify("auto-continue budget exhausted — session paused; check proxy or take over");
|
|
517
|
+
pausedByUs = true;
|
|
416
518
|
return;
|
|
417
519
|
}
|
|
418
|
-
|
|
419
|
-
const check = await probe();
|
|
420
520
|
const halfDead = check.ok && consecutiveErrors >= ESCALATE_AFTER;
|
|
421
521
|
|
|
422
522
|
if (!check.ok && !mayRepair()) {
|
|
@@ -431,7 +531,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
431
531
|
if (!(await repairProxy(ctx))) {
|
|
432
532
|
// On the half-dead path the pre-repair check was OK: don't trust the
|
|
433
533
|
// failed repair to mean we're offline — verify once more.
|
|
434
|
-
const post = await probe();
|
|
534
|
+
const post = await probe(ctx, true);
|
|
435
535
|
if (!post.ok) {
|
|
436
536
|
pausedByUs = true;
|
|
437
537
|
notify(ctx, "Proxy still down after repair attempts. Staying paused; watchdog will auto-resume once it's back.", "error");
|
|
@@ -463,10 +563,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
463
563
|
handler: async (args, ctx) => {
|
|
464
564
|
const sub = (args ?? "").trim().split(/\s+/)[0] || "check";
|
|
465
565
|
if (sub === "check") {
|
|
466
|
-
const check = await
|
|
566
|
+
const check = await probe(ctx, true);
|
|
467
567
|
notify(
|
|
468
568
|
ctx,
|
|
469
|
-
check.ok ? `Proxy OK (HTTP ${check.status}
|
|
569
|
+
check.ok ? `Proxy OK (HTTP ${check.status})` : `Proxy DOWN (${check.error ?? `HTTP ${check.status}`})`,
|
|
470
570
|
check.ok ? "info" : "error",
|
|
471
571
|
);
|
|
472
572
|
} else if (sub === "restart") {
|
|
@@ -475,7 +575,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
475
575
|
} else if (sub === "status") {
|
|
476
576
|
notify(
|
|
477
577
|
ctx,
|
|
478
|
-
`
|
|
578
|
+
`ping=${CHECK_URL} api=${API_URL_OVERRIDE ?? ctx.model?.baseUrl ?? "(none)"} stream=${STREAM_MIN_BYTES > 0 ? STREAM_URL : "off"} vpn=${VPN_SERVICE || "off"} ` +
|
|
579
|
+
`proxy=${CHECK_PROXY ?? "(env)"} shortcut="${SHORTCUT || "off"}" scheme=${USE_SCHEME ? "on" : "off"} ` +
|
|
479
580
|
`escalateAfter=${ESCALATE_AFTER} budget=${budgetLeft()}/${MAX_REPAIRS}/${Math.round(WINDOW_MS / 60_000)}min ` +
|
|
480
581
|
`watchdog=${WATCHDOG_MS}ms pausedByUs=${pausedByUs} consecErrs=${consecutiveErrors} failedRepairs=${failedRepairs}/${PAUSED_REPAIR_MAX} log=${LOG_FILE || "off"}`,
|
|
481
582
|
);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-proxy-guard",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.1",
|
|
4
4
|
"description": "Auto-recover pi sessions when the proxy drops: connectivity check, Shadowrocket restart/node-switch via Shortcuts or URL scheme, watchdog auto-resume.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|