pi-proxy-guard 0.5.1 → 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 +27 -11
- package/extensions/proxy-guard.ts +161 -32
- 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,15 +73,22 @@ 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 |
|
|
73
88
|
| `PI_PROXY_GUARD_PAUSED_REPAIRS` | `3` | active repair attempts per down-episode before passive watch |
|
|
74
89
|
| `PI_PROXY_GUARD_NOTIFY` | `macos,bark,webhook` | channels; `0`/`off` disables |
|
|
75
90
|
| `PI_PROXY_GUARD_NOTIFY_RESUME` | `0` | `1` also pushes when the session resumes |
|
|
91
|
+
| `PI_PROXY_GUARD_NOTIFY_FINISH_MS` | `0` | `>0` pushes "Pi finished" only for clean completes lasting ≥ this long — unlike pi-bark, never fires on error settles; e.g. `120000` |
|
|
76
92
|
| `PI_PROXY_GUARD_NOTIFY_COOLDOWN_MS` | `600000` | min gap between pushes |
|
|
77
93
|
| `PI_PROXY_GUARD_BARK` | _unset_ | Bark URL incl. device key |
|
|
78
94
|
| `PI_PROXY_GUARD_WEBHOOK` | _unset_ | generic JSON webhook |
|
|
@@ -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);
|
|
@@ -91,6 +118,10 @@ const NOTIFY_COOLDOWN_MS = Number(process.env.PI_PROXY_GUARD_NOTIFY_COOLDOWN_MS
|
|
|
91
118
|
const BARK = process.env.PI_PROXY_GUARD_BARK ?? "";
|
|
92
119
|
const WEBHOOK = process.env.PI_PROXY_GUARD_WEBHOOK ?? "";
|
|
93
120
|
const PUSH_TITLE = "pi proxy-guard";
|
|
121
|
+
/** Push a "Pi finished" notification only for clean completes lasting >=
|
|
122
|
+
* this long. 0 disables. Beats pi-bark's blanket agent_settled push:
|
|
123
|
+
* outcome-aware (no false "finished" on error settles) + duration gate. */
|
|
124
|
+
const NOTIFY_FINISH_MS = Number(process.env.PI_PROXY_GUARD_NOTIFY_FINISH_MS ?? 0);
|
|
94
125
|
const LOG_FILE =
|
|
95
126
|
process.env.PI_PROXY_GUARD_LOG !== ""
|
|
96
127
|
? (process.env.PI_PROXY_GUARD_LOG ?? join(homedir(), ".pi", "agent", "proxy-guard.log"))
|
|
@@ -124,27 +155,68 @@ interface CheckResult {
|
|
|
124
155
|
error?: string;
|
|
125
156
|
}
|
|
126
157
|
|
|
127
|
-
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> {
|
|
128
172
|
return new Promise((resolve) => {
|
|
129
173
|
const args = ["-sS", "-o", "/dev/null", "-w", "%{http_code}", "--max-time", String(Math.ceil(CHECK_TIMEOUT_MS / 1000))];
|
|
130
174
|
if (CHECK_PROXY) args.push("-x", CHECK_PROXY);
|
|
131
|
-
args.push(
|
|
175
|
+
args.push(url);
|
|
132
176
|
execFile("curl", args, { timeout: CHECK_TIMEOUT_MS + 2_000 }, (err, stdout) => {
|
|
133
177
|
const status = Number.parseInt((stdout ?? "").trim(), 10);
|
|
134
|
-
if (!err && status
|
|
178
|
+
if (!err && status > 0) resolve({ ok: true, status });
|
|
135
179
|
else resolve({ ok: false, status: Number.isFinite(status) ? status : undefined, error: (err?.message ?? "").split("\n")[0] });
|
|
136
180
|
});
|
|
137
181
|
});
|
|
138
182
|
}
|
|
139
183
|
|
|
140
|
-
|
|
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> {
|
|
141
188
|
return new Promise((resolve) => {
|
|
142
|
-
|
|
143
|
-
|
|
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
|
+
});
|
|
144
203
|
});
|
|
145
204
|
});
|
|
146
205
|
}
|
|
147
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
|
+
|
|
148
220
|
export default function (pi: ExtensionAPI) {
|
|
149
221
|
if (DISABLED) return;
|
|
150
222
|
|
|
@@ -165,6 +237,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
165
237
|
let ticking = false;
|
|
166
238
|
let lastRepairAt = 0;
|
|
167
239
|
let lastNotifyAt = 0;
|
|
240
|
+
let runStartedAt = 0;
|
|
241
|
+
/** outcome of the most recent settle boundary; agent_settled has none. */
|
|
242
|
+
let lastSettleOutcome = "";
|
|
168
243
|
/** Session-scoped watchdog + captured context. */
|
|
169
244
|
let watchdog: ReturnType<typeof setInterval> | undefined;
|
|
170
245
|
let sessionCtx: ExtensionContext | undefined;
|
|
@@ -195,6 +270,16 @@ export default function (pi: ExtensionAPI) {
|
|
|
195
270
|
return;
|
|
196
271
|
}
|
|
197
272
|
lastNotifyAt = Date.now();
|
|
273
|
+
pushNow(body);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** Duration-gated finish push; shares channels, unthrottled. */
|
|
277
|
+
function finishNotify(body: string): void {
|
|
278
|
+
if (NOTIFY_CHANNELS.length === 0 || NOTIFY_FINISH_MS <= 0) return;
|
|
279
|
+
pushNow(body);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function pushNow(body: string): void {
|
|
198
283
|
// Machine + cwd prefix, same format as @herbertgao/pi-bark, so pushes
|
|
199
284
|
// from pi-sync'd machines are attributable at a glance.
|
|
200
285
|
const full = `\ud83d\udcbb ${hostname()}\n\ud83d\udcc1 ${process.cwd()}\n${body}`;
|
|
@@ -220,14 +305,39 @@ export default function (pi: ExtensionAPI) {
|
|
|
220
305
|
}
|
|
221
306
|
}
|
|
222
307
|
|
|
223
|
-
/** checkProxy + episode bookkeeping: an OK probe resets the repair counter.
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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
|
+
}
|
|
229
339
|
}
|
|
230
|
-
return
|
|
340
|
+
return last;
|
|
231
341
|
}
|
|
232
342
|
|
|
233
343
|
/** True while we may still launch an active repair this down-episode. */
|
|
@@ -245,7 +355,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
245
355
|
try {
|
|
246
356
|
const recheck = async (): Promise<boolean> => {
|
|
247
357
|
await sleep(RECHECK_DELAY_MS);
|
|
248
|
-
const check = await
|
|
358
|
+
const check = await probe(ctx, true); // deep: verify real capacity before unpausing
|
|
249
359
|
if (!check.ok) log(`still down: ${check.error ?? `HTTP ${check.status}`}`);
|
|
250
360
|
return check.ok;
|
|
251
361
|
};
|
|
@@ -262,13 +372,14 @@ export default function (pi: ExtensionAPI) {
|
|
|
262
372
|
const viaScheme = async (): Promise<boolean> => {
|
|
263
373
|
if (!USE_SCHEME) return false;
|
|
264
374
|
notify(ctx, "Bouncing Shadowrocket via URL scheme…", "warning");
|
|
265
|
-
|
|
375
|
+
// `open -g`: background — do NOT activate/foreground Shadowrocket.
|
|
376
|
+
const stop = await execCmd("open", ["-g", SCHEME_STOP], 15_000);
|
|
266
377
|
if (!stop.ok) {
|
|
267
378
|
log(`scheme stop failed: ${stop.error}`);
|
|
268
379
|
return false;
|
|
269
380
|
}
|
|
270
381
|
await sleep(3_000);
|
|
271
|
-
const start = await execCmd("open", [SCHEME_START], 15_000);
|
|
382
|
+
const start = await execCmd("open", ["-g", SCHEME_START], 15_000);
|
|
272
383
|
if (!start.ok) {
|
|
273
384
|
log(`scheme start failed: ${start.error}`);
|
|
274
385
|
return false;
|
|
@@ -298,10 +409,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
298
409
|
// Only spend effort when pi actually needs the connection:
|
|
299
410
|
// a live run (internal retries benefit) or a paused-by-us session.
|
|
300
411
|
if (!runActive && !pausedByUs) return;
|
|
301
|
-
if (budgetLeft() <= 0) return;
|
|
302
412
|
ticking = true;
|
|
303
413
|
try {
|
|
304
|
-
const check = await probe();
|
|
414
|
+
const check = await probe(sessionCtx, false); // cheap ping for the periodic watch
|
|
305
415
|
if (check.ok) {
|
|
306
416
|
// Recovered while paused: resume.
|
|
307
417
|
if (pausedByUs && !runActive) {
|
|
@@ -324,6 +434,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
324
434
|
}
|
|
325
435
|
return;
|
|
326
436
|
}
|
|
437
|
+
// Repairing the network is free — only resumes/continues spend
|
|
438
|
+
// provider requests, so the budget gates those, not the repair.
|
|
327
439
|
if (await repairProxy(sessionCtx)) {
|
|
328
440
|
if (pausedByUs && !runActive && budgetLeft() > 0) {
|
|
329
441
|
chargeBudget();
|
|
@@ -358,14 +470,25 @@ export default function (pi: ExtensionAPI) {
|
|
|
358
470
|
|
|
359
471
|
pi.on("agent_start", () => {
|
|
360
472
|
runActive = true;
|
|
473
|
+
runStartedAt = Date.now();
|
|
361
474
|
pausedByUs = false; // whoever started the run, a fresh attempt is underway
|
|
362
475
|
});
|
|
363
|
-
pi.on("agent_settled", () => {
|
|
476
|
+
pi.on("agent_settled", (_event, ctx) => {
|
|
364
477
|
runActive = false;
|
|
478
|
+
// Genuine completion (not a failure disguised as "finished", like
|
|
479
|
+
// pi-bark's blanket settle push) that took a while — worth notifying;
|
|
480
|
+
// quick interactive turns are not.
|
|
481
|
+
if (NOTIFY_FINISH_MS > 0 && lastSettleOutcome === "completed" && runStartedAt > 0 && ctx.isIdle()) {
|
|
482
|
+
const mins = (Date.now() - runStartedAt) / 60_000;
|
|
483
|
+
if (Date.now() - runStartedAt >= NOTIFY_FINISH_MS) {
|
|
484
|
+
finishNotify(`Pi finished — run took ${mins.toFixed(1)}min`);
|
|
485
|
+
}
|
|
486
|
+
}
|
|
365
487
|
});
|
|
366
488
|
|
|
367
489
|
pi.on("agent_before_settle", async (event, ctx) => {
|
|
368
490
|
// Clean settle resets both counters: a new incident deserves a full budget.
|
|
491
|
+
lastSettleOutcome = event.outcome;
|
|
369
492
|
if (event.outcome !== "error") {
|
|
370
493
|
repairs = [];
|
|
371
494
|
consecutiveErrors = 0;
|
|
@@ -382,13 +505,18 @@ export default function (pi: ExtensionAPI) {
|
|
|
382
505
|
`settle error #${consecutiveErrors}; canContinue=${event.context.canContinue}; ` +
|
|
383
506
|
`budget=${budgetLeft()}/${MAX_REPAIRS}; pausedByUs=${pausedByUs}`,
|
|
384
507
|
);
|
|
385
|
-
|
|
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);
|
|
386
515
|
notify(ctx, `Auto-continue budget exhausted (${MAX_REPAIRS}/${Math.round(WINDOW_MS / 60_000)}min). Staying paused — take over manually.`, "warning");
|
|
387
516
|
systemNotify("auto-continue budget exhausted — session paused; check proxy or take over");
|
|
517
|
+
pausedByUs = true;
|
|
388
518
|
return;
|
|
389
519
|
}
|
|
390
|
-
|
|
391
|
-
const check = await probe();
|
|
392
520
|
const halfDead = check.ok && consecutiveErrors >= ESCALATE_AFTER;
|
|
393
521
|
|
|
394
522
|
if (!check.ok && !mayRepair()) {
|
|
@@ -403,7 +531,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
403
531
|
if (!(await repairProxy(ctx))) {
|
|
404
532
|
// On the half-dead path the pre-repair check was OK: don't trust the
|
|
405
533
|
// failed repair to mean we're offline — verify once more.
|
|
406
|
-
const post = await probe();
|
|
534
|
+
const post = await probe(ctx, true);
|
|
407
535
|
if (!post.ok) {
|
|
408
536
|
pausedByUs = true;
|
|
409
537
|
notify(ctx, "Proxy still down after repair attempts. Staying paused; watchdog will auto-resume once it's back.", "error");
|
|
@@ -435,10 +563,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
435
563
|
handler: async (args, ctx) => {
|
|
436
564
|
const sub = (args ?? "").trim().split(/\s+/)[0] || "check";
|
|
437
565
|
if (sub === "check") {
|
|
438
|
-
const check = await
|
|
566
|
+
const check = await probe(ctx, true);
|
|
439
567
|
notify(
|
|
440
568
|
ctx,
|
|
441
|
-
check.ok ? `Proxy OK (HTTP ${check.status}
|
|
569
|
+
check.ok ? `Proxy OK (HTTP ${check.status})` : `Proxy DOWN (${check.error ?? `HTTP ${check.status}`})`,
|
|
442
570
|
check.ok ? "info" : "error",
|
|
443
571
|
);
|
|
444
572
|
} else if (sub === "restart") {
|
|
@@ -447,7 +575,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
447
575
|
} else if (sub === "status") {
|
|
448
576
|
notify(
|
|
449
577
|
ctx,
|
|
450
|
-
`
|
|
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"} ` +
|
|
451
580
|
`escalateAfter=${ESCALATE_AFTER} budget=${budgetLeft()}/${MAX_REPAIRS}/${Math.round(WINDOW_MS / 60_000)}min ` +
|
|
452
581
|
`watchdog=${WATCHDOG_MS}ms pausedByUs=${pausedByUs} consecErrs=${consecutiveErrors} failedRepairs=${failedRepairs}/${PAUSED_REPAIR_MAX} log=${LOG_FILE || "off"}`,
|
|
453
582
|
);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-proxy-guard",
|
|
3
|
-
"version": "0.
|
|
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": {
|