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 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** (`curl https://www.google.com/generate_204`, inheriting the same `https_proxy` env pi uses).
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: the Shortcuts shortcut
41
+ ## Setup: repairing Shadowrocket on macOS
42
42
 
43
- Default name: **Reconnect Shadowrocket** (set `PI_PROXY_GUARD_SHORTCUT` to rename). Build it in macOS Shortcuts — the user already has `Disconnect VPN` / `Connect VPN` shortcuts whose actions can be reused:
43
+ Zero-setup (verified on the Apple-Silicon iOS-app runtime): the extension runs
44
44
 
45
- 1. `Disconnect VPN` (or Shadowrocket "Stop" action)
46
- 2. Wait 2s
47
- 3. `Connect VPN` (or Shadowrocket "Start" action)
48
- 4. Wait 5s
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
- Or, zero-setup: it falls back to `open shadowrocket://stop` → `shadowrocket://start` automatically (works when Shadowrocket registers its URL scheme — on macOS this needs the Apple Silicon iOS app).
59
+ ### Verification note
51
60
 
52
- Node switching is intentionally left to the proxy client (chain-proxy friendly). To make the *repair* more effective, build the node switch into the shortcut itself — e.g. select a different entry node inside Shadowrocket before reconnecting.
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` | health-check target; for tighter signal use your provider's API URL |
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` | `10000` | check timeout |
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 10000
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://stop
41
- * PI_PROXY_GUARD_SCHEME_START shadowrocket://start
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 ?? 10_000);
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
- const SCHEME_STOP = process.env.PI_PROXY_GUARD_SCHEME_STOP ?? "shadowrocket://stop";
77
- const SCHEME_START = process.env.PI_PROXY_GUARD_SCHEME_START ?? "shadowrocket://start";
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 checkProxy(): Promise<CheckResult> {
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(CHECK_URL);
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 >= 200 && status < 400) resolve({ ok: true, 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
- function execCmd(cmd: string, args: string[], timeoutMs: number): Promise<{ ok: boolean; error?: string }> {
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
- execFile(cmd, args, { timeout: timeoutMs }, (err, _stdout, stderr) => {
143
- resolve(err ? { ok: false, error: ((stderr || "") + err.message).split("\n")[0].trim() } : { ok: true });
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
- async function probe(): Promise<CheckResult> {
225
- const check = await checkProxy();
226
- if (check.ok) {
227
- failedRepairs = 0;
228
- gaveUpNotified = false;
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 check;
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 checkProxy();
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
- const stop = await execCmd("open", [SCHEME_STOP], 15_000);
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
- if (budgetLeft() <= 0) {
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 checkProxy();
566
+ const check = await probe(ctx, true);
439
567
  notify(
440
568
  ctx,
441
- check.ok ? `Proxy OK (HTTP ${check.status}, ${CHECK_URL})` : `Proxy DOWN (${check.error ?? `HTTP ${check.status}`}, ${CHECK_URL})`,
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
- `url=${CHECK_URL} proxy=${CHECK_PROXY ?? "(env)"} shortcut="${SHORTCUT || "off"}" scheme=${USE_SCHEME ? "on" : "off"} ` +
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.5.1",
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": {