pi-proxy-guard 0.5.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 pi-dal
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,89 @@
1
+ # pi-proxy-guard
2
+
3
+ Auto-recover [pi](https://github.com/earendil-works/pi) sessions when the proxy drops mid-run.
4
+
5
+ When all provider retries fail (`Retry failed after N attempts` / `Stream ended without finish_reason`), pi pauses the session. This extension:
6
+
7
+ 1. **Checks connectivity** (`curl https://www.google.com/generate_204`, inheriting the same `https_proxy` env pi uses).
8
+ 2. **Auto-continues** the run if the proxy is fine (transient stream cut).
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
+ 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.
11
+ - **If the proxy stays down**: at most `PAUSED_REPAIRS` (default 3) active repair attempts per down-episode; afterwards it switches to a cheap passive watch (one curl per `WATCHDOG_MS`) and **still auto-resumes** the moment the proxy verifies OK — e.g. after you fix it manually. It never gives up on resuming, it just stops hammering Shortcuts.
12
+ 6. **Notifications**: on *repeated* failure (active repairs exhausted, or continue-budget exhausted) it pushes a notification — to your iPhone via Bark, a generic webhook, and/or macOS Notification Center. Push traffic bypasses the proxy (`--noproxy '*'`) so it reaches you even while the proxy is dead. Push bodies start with `💻 <hostname>` + `📁 <cwd>` so pushes from pi-sync'd machines are attributable.
13
+
14
+ ## Notifications (iPhone)
15
+
16
+ 1. Install [Bark](https://bark.day.app) on your iPhone, copy the push URL it shows (like `https://api.day.app/AbCdEfGhIjKlMnOpQr/`).
17
+ 2. Export once (shell rc) or per-launch:
18
+ ```bash
19
+ export PI_PROXY_GUARD_BARK="https://api.day.app/<your-device-key>"
20
+ ```
21
+ Push uses Bark's POST form (`title`/`body`/`group`/`level=timeSensitive`), requests are `curl --noproxy '*' -m 10`.
22
+ 3. Optional generic webhook (Pushcut / 企业微信 / n8n — POSTs `{"title","body"}`):
23
+ ```bash
24
+ export PI_PROXY_GUARD_WEBHOOK="https://your.webhook/endpoint"
25
+ ```
26
+
27
+ Channels: `PI_PROXY_GUARD_NOTIFY="macos,bark,webhook"` (default; unconfigured channels are skipped), `"0"` silences all. Pushes are throttled to one per `NOTIFY_COOLDOWN_MS` (10min) — while things stay broken it acts as a periodic reminder.
28
+ 5. **Half-dead node detection**: if the health check passes but streams keep cutting (≥ `ESCALATE_AFTER` consecutive error settles), it repairs anyway — a flapping node is usually not the same as a dead one.
29
+ 6. **Anti-loop budget**: at most `MAX_REPAIRS` auto-continues per `WINDOW_MS` (rolling). Exhausted → stays paused; any clean run resets the budget.
30
+
31
+ ## Install
32
+
33
+ ```bash
34
+ pi install git:github.com/pi-dal/pi-proxy-guard
35
+ # or from a local checkout:
36
+ pi install ~/Developer/pi-proxy-guard
37
+ ```
38
+
39
+ Restart pi. `/proxyguard check|restart|status` verifies it's live; logs go to `~/.pi/agent/proxy-guard.log`.
40
+
41
+ ## Setup: the Shortcuts shortcut
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:
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
49
+
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).
51
+
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.
53
+
54
+ ## Tuning pi's own retries
55
+
56
+ `~/.pi/agent/settings.json` — more internal retries give the watchdog runway to repair mid-run, so fewer pauses ever happen:
57
+
58
+ ```json
59
+ "retry": { "enabled": true, "maxRetries": 5, "baseDelayMs": 4000, "maxAgentDelayMs": 60000 }
60
+ ```
61
+
62
+ ## Env knobs
63
+
64
+ | Var | Default | Meaning |
65
+ |---|---|---|
66
+ | `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 |
68
+ | `PI_PROXY_GUARD_PROXY` | _(env)_ | explicit `-x` proxy for the check |
69
+ | `PI_PROXY_GUARD_TIMEOUT_MS` | `10000` | check timeout |
70
+ | `PI_PROXY_GUARD_SHORTCUT` | `Reconnect Shadowrocket` | `""` disables |
71
+ | `PI_PROXY_GUARD_SCHEME` | `1` | `0` disables `shadowrocket://` fallback |
72
+ | `PI_PROXY_GUARD_ESCALATE_AFTER` | `2` | consecutive errors → repair despite OK ping |
73
+ | `PI_PROXY_GUARD_PAUSED_REPAIRS` | `3` | active repair attempts per down-episode before passive watch |
74
+ | `PI_PROXY_GUARD_NOTIFY` | `macos,bark,webhook` | channels; `0`/`off` disables |
75
+ | `PI_PROXY_GUARD_NOTIFY_RESUME` | `0` | `1` also pushes when the session resumes |
76
+ | `PI_PROXY_GUARD_NOTIFY_COOLDOWN_MS` | `600000` | min gap between pushes |
77
+ | `PI_PROXY_GUARD_BARK` | _unset_ | Bark URL incl. device key |
78
+ | `PI_PROXY_GUARD_WEBHOOK` | _unset_ | generic JSON webhook |
79
+ | `PI_PROXY_GUARD_REPAIR_COOLDOWN_MS` | `90000` | min gap between repairs |
80
+ | `PI_PROXY_GUARD_RECHECK_DELAY_MS` | `8000` | wait after repair before recheck |
81
+ | `PI_PROXY_GUARD_BACKOFF_MS` | `3000` | wait before continue on OK check |
82
+ | `PI_PROXY_GUARD_MAX_REPAIRS` | `5` | auto-continues per window |
83
+ | `PI_PROXY_GUARD_WINDOW_MS` | `600000` | budget window |
84
+ | `PI_PROXY_GUARD_WATCHDOG_MS` | `60000` | `0` disables |
85
+ | `PI_PROXY_GUARD_LOG` | `~/.pi/agent/proxy-guard.log` | `""` disables |
86
+
87
+ ## Structural fix (if nodes keep flapping)
88
+
89
+ A mid-stream cut can't be undone — only prevented. If this keeps happening, put a local proxy with node failover between pi and the subscriptions: mihomo/clash `url-test` or `fallback` group → point `https_proxy` at `127.0.0.1:7890`. Pi's retries then hit a healthy node automatically and extension repairs become rare.
@@ -0,0 +1,459 @@
1
+ /**
2
+ * Proxy Guard — auto-recover Pi when the upstream proxy breaks.
3
+ *
4
+ * Trigger: `agent_before_settle` with outcome "error" = all provider retries
5
+ * exhausted and Pi is about to pause. After "Retry failed after N attempts"
6
+ * the errored assistant message is the last context message, so boundary
7
+ * canContinue is FALSE and a bare {continue:true} is rejected; we append a
8
+ * custom_message draft (projects to a `user` message for the LLM) with it.
9
+ *
10
+ * Flow on settle-error:
11
+ * 1. Wait for any in-flight watchdog repair to finish (they serialize).
12
+ * 2. Budget: max MAX_REPAIRS auto-continues per WINDOW_MS (rolling); a clean
13
+ * settle resets it. Exhausted -> stay paused, take over manually.
14
+ * 3. curl CHECK_URL (inherits env proxy vars = same network path as Pi).
15
+ * 4. Repair decision:
16
+ * - check down -> repair, then continue iff OK
17
+ * - check OK but ESCALATE_AFTER consecutive error settles
18
+ * (half-dead node: ping fine, streams cut) -> repair anyway
19
+ * - check OK -> backoff -> continue
20
+ * 5. Repair chain (first success wins, each followed by a recheck):
21
+ * a. macOS Shortcuts shortcut (default "Reconnect Shadowrocket")
22
+ * b. scheme bounce: shadowrocket://stop -> shadowrocket://start
23
+ * If still down -> pausedByUs; watchdog auto-resumes once verified OK.
24
+ * Active repairs are capped at PAUSED_REPAIR_MAX per down-episode;
25
+ * afterwards the watchdog keeps a cheap passive watch and still
26
+ * auto-resumes when the proxy comes back on its own.
27
+ *
28
+ * Watchdog (WATCHDOG_MS): repairs the proxy while a run is mid-retry or while
29
+ * paused-by-us (never while cleanly idle, so manually turning VPN off is
30
+ * respected). On recovery + pausedByUs it sends sendUserMessage("continue").
31
+ *
32
+ * Env knobs (defaults):
33
+ * PI_PROXY_GUARD=0 disable
34
+ * PI_PROXY_GUARD_URL https://www.google.com/generate_204
35
+ * PI_PROXY_GUARD_PROXY (unset = inherit env proxy vars)
36
+ * PI_PROXY_GUARD_TIMEOUT_MS 10000
37
+ * PI_PROXY_GUARD_SHORTCUT "Reconnect Shadowrocket" ("" disables)
38
+ * PI_PROXY_GUARD_SHORTCUT_TIMEOUT_MS 120000
39
+ * PI_PROXY_GUARD_SCHEME "1" (shadowrocket:// fallback)
40
+ * PI_PROXY_GUARD_SCHEME_STOP shadowrocket://stop
41
+ * PI_PROXY_GUARD_SCHEME_START shadowrocket://start
42
+ * PI_PROXY_GUARD_ESCALATE_AFTER 2 (consecutive errors w/ OK check)
43
+ * PI_PROXY_GUARD_PAUSED_REPAIRS 3 (active repair cap while paused)
44
+ * PI_PROXY_GUARD_REPAIR_COOLDOWN_MS 90000
45
+ * PI_PROXY_GUARD_RECHECK_DELAY_MS 8000
46
+ * PI_PROXY_GUARD_BACKOFF_MS 3000
47
+ * PI_PROXY_GUARD_MAX_REPAIRS 5
48
+ * PI_PROXY_GUARD_WINDOW_MS 600000
49
+ * PI_PROXY_GUARD_WATCHDOG_MS 60000 (0 disables)
50
+ * PI_PROXY_GUARD_NOTIFY "macos,bark,webhook"
51
+ * (channels, "0"/off disables all)
52
+ * PI_PROXY_GUARD_NOTIFY_RESUME "0" (1 also notifies on recovery)
53
+ * PI_PROXY_GUARD_NOTIFY_COOLDOWN_MS 600000
54
+ * PI_PROXY_GUARD_BARK "" Bark push URL, e.g.
55
+ * https://api.day.app/<device_key>
56
+ * PI_PROXY_GUARD_WEBHOOK "" POSTs {title,body} JSON
57
+ * Push requests always go DIRECT (--noproxy '*'): the whole point is
58
+ * reaching you while the proxy is dead.
59
+ * PI_PROXY_GUARD_LOG ~/.pi/agent/proxy-guard.log ("" disables)
60
+ */
61
+
62
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
63
+ import { execFile } from "node:child_process";
64
+ import { appendFileSync, mkdirSync } from "node:fs";
65
+ import { homedir, hostname } from "node:os";
66
+ import { dirname, join } from "node:path";
67
+ import { setTimeout as sleep } from "node:timers/promises";
68
+
69
+ const DISABLED = process.env.PI_PROXY_GUARD === "0";
70
+ const CHECK_URL = process.env.PI_PROXY_GUARD_URL ?? "https://www.google.com/generate_204";
71
+ const CHECK_PROXY = process.env.PI_PROXY_GUARD_PROXY;
72
+ const CHECK_TIMEOUT_MS = Number(process.env.PI_PROXY_GUARD_TIMEOUT_MS ?? 10_000);
73
+ const SHORTCUT = process.env.PI_PROXY_GUARD_SHORTCUT ?? "Reconnect Shadowrocket";
74
+ const SHORTCUT_TIMEOUT_MS = Number(process.env.PI_PROXY_GUARD_SHORTCUT_TIMEOUT_MS ?? 120_000);
75
+ 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";
78
+ const ESCALATE_AFTER = Number(process.env.PI_PROXY_GUARD_ESCALATE_AFTER ?? 2);
79
+ const PAUSED_REPAIR_MAX = Number(process.env.PI_PROXY_GUARD_PAUSED_REPAIRS ?? 3);
80
+ const REPAIR_COOLDOWN_MS = Number(process.env.PI_PROXY_GUARD_REPAIR_COOLDOWN_MS ?? 90_000);
81
+ const RECHECK_DELAY_MS = Number(process.env.PI_PROXY_GUARD_RECHECK_DELAY_MS ?? 8_000);
82
+ const BACKOFF_MS = Number(process.env.PI_PROXY_GUARD_BACKOFF_MS ?? 3_000);
83
+ const MAX_REPAIRS = Number(process.env.PI_PROXY_GUARD_MAX_REPAIRS ?? 5);
84
+ const WINDOW_MS = Number(process.env.PI_PROXY_GUARD_WINDOW_MS ?? 600_000);
85
+ const WATCHDOG_MS = Number(process.env.PI_PROXY_GUARD_WATCHDOG_MS ?? 60_000);
86
+ const NOTIFY_CHANNELS = process.env.PI_PROXY_GUARD_NOTIFY === "0" || process.env.PI_PROXY_GUARD_NOTIFY === "off"
87
+ ? []
88
+ : (process.env.PI_PROXY_GUARD_NOTIFY ?? "macos,bark,webhook").split(",").map((s) => s.trim()).filter(Boolean);
89
+ const NOTIFY_RESUME = process.env.PI_PROXY_GUARD_NOTIFY_RESUME === "1";
90
+ const NOTIFY_COOLDOWN_MS = Number(process.env.PI_PROXY_GUARD_NOTIFY_COOLDOWN_MS ?? 600_000);
91
+ const BARK = process.env.PI_PROXY_GUARD_BARK ?? "";
92
+ const WEBHOOK = process.env.PI_PROXY_GUARD_WEBHOOK ?? "";
93
+ const PUSH_TITLE = "pi proxy-guard";
94
+ const LOG_FILE =
95
+ process.env.PI_PROXY_GUARD_LOG !== ""
96
+ ? (process.env.PI_PROXY_GUARD_LOG ?? join(homedir(), ".pi", "agent", "proxy-guard.log"))
97
+ : "";
98
+
99
+ const NUDGE_CUSTOM_TYPE = "proxy-guard-recovery";
100
+ const NUDGE_TEXT =
101
+ "[proxy-guard] The previous model request failed with a network error (stream cut, retries exhausted). " +
102
+ "Connectivity has been verified again. Continue exactly where you left off.";
103
+ const RESUME_TEXT = "continue";
104
+
105
+ function log(message: string): void {
106
+ if (!LOG_FILE) return;
107
+ try {
108
+ mkdirSync(dirname(LOG_FILE), { recursive: true });
109
+ appendFileSync(LOG_FILE, `${new Date().toISOString()} ${message}\n`);
110
+ } catch {
111
+ /* never let logging break the guard */
112
+ }
113
+ }
114
+
115
+ function notify(ctx: ExtensionContext, message: string, level: "info" | "warning" | "error" = "info"): void {
116
+ log(`[${level}] ${message}`);
117
+ if (ctx.hasUI) ctx.ui.notify(`[proxy-guard] ${message}`, level);
118
+ else console.error(`[proxy-guard] ${message}`);
119
+ }
120
+
121
+ interface CheckResult {
122
+ ok: boolean;
123
+ status?: number;
124
+ error?: string;
125
+ }
126
+
127
+ function checkProxy(): Promise<CheckResult> {
128
+ return new Promise((resolve) => {
129
+ const args = ["-sS", "-o", "/dev/null", "-w", "%{http_code}", "--max-time", String(Math.ceil(CHECK_TIMEOUT_MS / 1000))];
130
+ if (CHECK_PROXY) args.push("-x", CHECK_PROXY);
131
+ args.push(CHECK_URL);
132
+ execFile("curl", args, { timeout: CHECK_TIMEOUT_MS + 2_000 }, (err, stdout) => {
133
+ const status = Number.parseInt((stdout ?? "").trim(), 10);
134
+ if (!err && status >= 200 && status < 400) resolve({ ok: true, status });
135
+ else resolve({ ok: false, status: Number.isFinite(status) ? status : undefined, error: (err?.message ?? "").split("\n")[0] });
136
+ });
137
+ });
138
+ }
139
+
140
+ function execCmd(cmd: string, args: string[], timeoutMs: number): Promise<{ ok: boolean; error?: string }> {
141
+ 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 });
144
+ });
145
+ });
146
+ }
147
+
148
+ export default function (pi: ExtensionAPI) {
149
+ if (DISABLED) return;
150
+
151
+ /** Timestamps of issued auto-continues, pruned against WINDOW_MS. */
152
+ let repairs: number[] = [];
153
+ /** Consecutive settle-errors without a clean run in between. */
154
+ let consecutiveErrors = 0;
155
+ /** Set when we stayed paused because the proxy was down; watchdog resumes us. */
156
+ let pausedByUs = false;
157
+ /** True while an agent run is live (agent_start .. agent_settled). */
158
+ let runActive = false;
159
+ /** Repairs attempted since the last successful probe (per down-episode). */
160
+ let failedRepairs = 0;
161
+ let gaveUpNotified = false;
162
+ /** Re-entrancy + serialization. */
163
+ let busy = false;
164
+ let repairing = false;
165
+ let ticking = false;
166
+ let lastRepairAt = 0;
167
+ let lastNotifyAt = 0;
168
+ /** Session-scoped watchdog + captured context. */
169
+ let watchdog: ReturnType<typeof setInterval> | undefined;
170
+ let sessionCtx: ExtensionContext | undefined;
171
+
172
+ const budgetLeft = (): number => {
173
+ const cutoff = Date.now() - WINDOW_MS;
174
+ repairs = repairs.filter((t) => t > cutoff);
175
+ return MAX_REPAIRS - repairs.length;
176
+ };
177
+ const chargeBudget = (): void => {
178
+ repairs.push(Date.now());
179
+ };
180
+
181
+ /** Serialize settle handler behind an in-flight watchdog repair. */
182
+ async function waitForRepair(deadlineMs: number): Promise<void> {
183
+ const deadline = Date.now() + deadlineMs;
184
+ while (repairing && Date.now() < deadline) await sleep(500);
185
+ }
186
+
187
+ /** Push notifications (iPhone via Bark, generic webhook, macOS fallback).
188
+ * Throttled: each fire doubles as a periodic "still broken" reminder.
189
+ * Push requests bypass the proxy (--noproxy '*') — they must reach the
190
+ * phone exactly when the proxy is dead. */
191
+ function systemNotify(body: string): void {
192
+ if (NOTIFY_CHANNELS.length === 0) return;
193
+ if (Date.now() - lastNotifyAt < NOTIFY_COOLDOWN_MS) {
194
+ log("notification throttled");
195
+ return;
196
+ }
197
+ lastNotifyAt = Date.now();
198
+ // Machine + cwd prefix, same format as @herbertgao/pi-bark, so pushes
199
+ // from pi-sync'd machines are attributable at a glance.
200
+ const full = `\ud83d\udcbb ${hostname()}\n\ud83d\udcc1 ${process.cwd()}\n${body}`;
201
+ const direct = ["--noproxy", "*", "-sS", "-m", "10"];
202
+ for (const ch of NOTIFY_CHANNELS) {
203
+ if (ch === "macos") {
204
+ const safe = full.replace(/["\\]/g, "'");
205
+ void execCmd("osascript", ["-e", `display notification "${safe}" with title "${PUSH_TITLE}" sound name "Ping"`], 10_000).then((r) => {
206
+ if (!r.ok) log(`macos notify failed: ${r.error}`);
207
+ });
208
+ } else if (ch === "bark" && BARK) {
209
+ // POST form (same shape as Bark docs): robust for unicode/special chars.
210
+ void execCmd("curl", [...direct, "-o", "/dev/null", "-w", "%{http_code}", "-X", "POST", BARK,
211
+ "-d", `title=${PUSH_TITLE}`, "-d", `body=${full}`, "-d", "group=pi", "-d", "level=timeSensitive"], 15_000).then((r) => {
212
+ log(`bark push ${r.ok ? "sent" : `failed: ${r.error}`}`);
213
+ });
214
+ } else if (ch === "webhook" && WEBHOOK) {
215
+ const payload = JSON.stringify({ title: PUSH_TITLE, body: full }).replace(/'/g, "'\\''");
216
+ void execCmd("curl", [...direct, "-X", "POST", "-H", "Content-Type: application/json", "-d", payload, "-o", "/dev/null", "-w", "%{http_code}", WEBHOOK], 15_000).then((r) => {
217
+ log(`webhook push ${r.ok ? "sent" : `failed: ${r.error}`}`);
218
+ });
219
+ }
220
+ }
221
+ }
222
+
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;
229
+ }
230
+ return check;
231
+ }
232
+
233
+ /** True while we may still launch an active repair this down-episode. */
234
+ const mayRepair = (): boolean => failedRepairs < PAUSED_REPAIR_MAX;
235
+
236
+ /** Restart/switch the proxy. first success wins; each method rechecks. */
237
+ async function repairProxy(ctx: ExtensionContext): Promise<boolean> {
238
+ if (Date.now() - lastRepairAt < REPAIR_COOLDOWN_MS) {
239
+ log("repair skipped: cooldown");
240
+ return false;
241
+ }
242
+ repairing = true;
243
+ lastRepairAt = Date.now();
244
+ failedRepairs++;
245
+ try {
246
+ const recheck = async (): Promise<boolean> => {
247
+ await sleep(RECHECK_DELAY_MS);
248
+ const check = await checkProxy();
249
+ if (!check.ok) log(`still down: ${check.error ?? `HTTP ${check.status}`}`);
250
+ return check.ok;
251
+ };
252
+ const viaShortcut = async (): Promise<boolean> => {
253
+ if (!SHORTCUT) return false;
254
+ notify(ctx, `Running shortcut "${SHORTCUT}"…`, "warning");
255
+ const res = await execCmd("shortcuts", ["run", SHORTCUT], SHORTCUT_TIMEOUT_MS);
256
+ if (!res.ok) {
257
+ log(`shortcut failed: ${res.error}`);
258
+ return false;
259
+ }
260
+ return recheck();
261
+ };
262
+ const viaScheme = async (): Promise<boolean> => {
263
+ if (!USE_SCHEME) return false;
264
+ notify(ctx, "Bouncing Shadowrocket via URL scheme…", "warning");
265
+ const stop = await execCmd("open", [SCHEME_STOP], 15_000);
266
+ if (!stop.ok) {
267
+ log(`scheme stop failed: ${stop.error}`);
268
+ return false;
269
+ }
270
+ await sleep(3_000);
271
+ const start = await execCmd("open", [SCHEME_START], 15_000);
272
+ if (!start.ok) {
273
+ log(`scheme start failed: ${start.error}`);
274
+ return false;
275
+ }
276
+ return recheck();
277
+ };
278
+
279
+ for (const m of [viaShortcut, viaScheme]) {
280
+ if (await m()) return true;
281
+ }
282
+ return false;
283
+ } finally {
284
+ repairing = false;
285
+ }
286
+ }
287
+
288
+ pi.on("session_start", (_event, ctx) => {
289
+ sessionCtx = ctx;
290
+ pausedByUs = false;
291
+ repairs = [];
292
+ consecutiveErrors = 0;
293
+ runActive = false;
294
+ if (WATCHDOG_MS > 0 && !watchdog) {
295
+ watchdog = setInterval(() => {
296
+ void (async () => {
297
+ if (ticking || busy || repairing || !sessionCtx) return;
298
+ // Only spend effort when pi actually needs the connection:
299
+ // a live run (internal retries benefit) or a paused-by-us session.
300
+ if (!runActive && !pausedByUs) return;
301
+ if (budgetLeft() <= 0) return;
302
+ ticking = true;
303
+ try {
304
+ const check = await probe();
305
+ if (check.ok) {
306
+ // Recovered while paused: resume.
307
+ if (pausedByUs && !runActive) {
308
+ chargeBudget();
309
+ pausedByUs = false;
310
+ notify(sessionCtx, "Proxy verified OK — resuming paused session.");
311
+ if (NOTIFY_RESUME) systemNotify("proxy recovered — session resumed");
312
+ pi.sendUserMessage(RESUME_TEXT, { deliverAs: "followUp" });
313
+ }
314
+ return;
315
+ }
316
+ // Down: active repairs are capped at PAUSED_REPAIR_MAX per
317
+ // down-episode; afterwards we keep a cheap passive watch and
318
+ // still auto-resume when the proxy recovers on its own.
319
+ if (!mayRepair()) {
320
+ if (!gaveUpNotified) {
321
+ gaveUpNotified = true;
322
+ notify(sessionCtx, `Active repair gave up after ${PAUSED_REPAIR_MAX} attempts — passive watch only; will still auto-resume on recovery.`, "warning");
323
+ systemNotify(`proxy still down after ${PAUSED_REPAIR_MAX} repair attempts — session paused; will auto-resume on recovery`);
324
+ }
325
+ return;
326
+ }
327
+ if (await repairProxy(sessionCtx)) {
328
+ if (pausedByUs && !runActive && budgetLeft() > 0) {
329
+ chargeBudget();
330
+ pausedByUs = false;
331
+ notify(sessionCtx, "Proxy repaired — resuming paused session.");
332
+ if (NOTIFY_RESUME) systemNotify("proxy recovered — session resumed");
333
+ pi.sendUserMessage(RESUME_TEXT, { deliverAs: "followUp" });
334
+ } else {
335
+ log("watchdog repaired proxy mid-run");
336
+ }
337
+ }
338
+ } catch (err) {
339
+ log(`watchdog error: ${err instanceof Error ? err.message : String(err)}`);
340
+ } finally {
341
+ ticking = false;
342
+ }
343
+ })();
344
+ }, WATCHDOG_MS);
345
+ watchdog.unref?.();
346
+ }
347
+ });
348
+
349
+ pi.on("session_shutdown", () => {
350
+ if (watchdog) {
351
+ clearInterval(watchdog);
352
+ watchdog = undefined;
353
+ }
354
+ sessionCtx = undefined;
355
+ pausedByUs = false;
356
+ runActive = false;
357
+ });
358
+
359
+ pi.on("agent_start", () => {
360
+ runActive = true;
361
+ pausedByUs = false; // whoever started the run, a fresh attempt is underway
362
+ });
363
+ pi.on("agent_settled", () => {
364
+ runActive = false;
365
+ });
366
+
367
+ pi.on("agent_before_settle", async (event, ctx) => {
368
+ // Clean settle resets both counters: a new incident deserves a full budget.
369
+ if (event.outcome !== "error") {
370
+ repairs = [];
371
+ consecutiveErrors = 0;
372
+ return;
373
+ }
374
+ if (busy) return;
375
+ busy = true;
376
+ try {
377
+ consecutiveErrors++;
378
+ // If the watchdog is mid-repair, ride on its result instead of starting another.
379
+ await waitForRepair(SHORTCUT_TIMEOUT_MS + 30_000);
380
+
381
+ log(
382
+ `settle error #${consecutiveErrors}; canContinue=${event.context.canContinue}; ` +
383
+ `budget=${budgetLeft()}/${MAX_REPAIRS}; pausedByUs=${pausedByUs}`,
384
+ );
385
+ if (budgetLeft() <= 0) {
386
+ notify(ctx, `Auto-continue budget exhausted (${MAX_REPAIRS}/${Math.round(WINDOW_MS / 60_000)}min). Staying paused — take over manually.`, "warning");
387
+ systemNotify("auto-continue budget exhausted — session paused; check proxy or take over");
388
+ return;
389
+ }
390
+
391
+ const check = await probe();
392
+ const halfDead = check.ok && consecutiveErrors >= ESCALATE_AFTER;
393
+
394
+ if (!check.ok && !mayRepair()) {
395
+ // Repair already gave up this down-episode — don't hammer Shortcuts.
396
+ pausedByUs = true;
397
+ notify(ctx, `Proxy down; active repairs exhausted (${PAUSED_REPAIR_MAX}). Passive watch until it's back.`, "error");
398
+ systemNotify(`proxy down, repairs exhausted (${PAUSED_REPAIR_MAX}) — session paused`);
399
+ return;
400
+ }
401
+ if (!check.ok || halfDead) {
402
+ // Down, or ping-OK but streams keep dying -> repair.
403
+ if (!(await repairProxy(ctx))) {
404
+ // On the half-dead path the pre-repair check was OK: don't trust the
405
+ // failed repair to mean we're offline — verify once more.
406
+ const post = await probe();
407
+ if (!post.ok) {
408
+ pausedByUs = true;
409
+ notify(ctx, "Proxy still down after repair attempts. Staying paused; watchdog will auto-resume once it's back.", "error");
410
+ return;
411
+ }
412
+ }
413
+ notify(ctx, halfDead ? "Half-dead node — repaired before continuing." : "Proxy restored. Auto-continuing…");
414
+ } else if (BACKOFF_MS > 0) {
415
+ await sleep(BACKOFF_MS);
416
+ notify(ctx, `Proxy OK (HTTP ${check.status}). Auto-continuing after error…`);
417
+ }
418
+
419
+ chargeBudget();
420
+ pausedByUs = false;
421
+
422
+ // Errored assistant message = last context message -> canContinue=false.
423
+ // Append a custom_message draft (projects to a `user` message) to unblock.
424
+ const entries = event.context.canContinue
425
+ ? undefined
426
+ : [{ type: "custom_message" as const, customType: NUDGE_CUSTOM_TYPE, content: NUDGE_TEXT, display: false }];
427
+ return entries ? { entries, continue: true } : { continue: true };
428
+ } finally {
429
+ busy = false;
430
+ }
431
+ });
432
+
433
+ pi.registerCommand("proxyguard", {
434
+ description: "Proxy Guard: check/restart/status",
435
+ handler: async (args, ctx) => {
436
+ const sub = (args ?? "").trim().split(/\s+/)[0] || "check";
437
+ if (sub === "check") {
438
+ const check = await checkProxy();
439
+ notify(
440
+ ctx,
441
+ check.ok ? `Proxy OK (HTTP ${check.status}, ${CHECK_URL})` : `Proxy DOWN (${check.error ?? `HTTP ${check.status}`}, ${CHECK_URL})`,
442
+ check.ok ? "info" : "error",
443
+ );
444
+ } else if (sub === "restart") {
445
+ const repaired = await repairProxy(ctx);
446
+ notify(ctx, repaired ? "Proxy restored." : "Proxy still down after repair.", repaired ? "info" : "error");
447
+ } else if (sub === "status") {
448
+ notify(
449
+ ctx,
450
+ `url=${CHECK_URL} proxy=${CHECK_PROXY ?? "(env)"} shortcut="${SHORTCUT || "off"}" scheme=${USE_SCHEME ? "on" : "off"} ` +
451
+ `escalateAfter=${ESCALATE_AFTER} budget=${budgetLeft()}/${MAX_REPAIRS}/${Math.round(WINDOW_MS / 60_000)}min ` +
452
+ `watchdog=${WATCHDOG_MS}ms pausedByUs=${pausedByUs} consecErrs=${consecutiveErrors} failedRepairs=${failedRepairs}/${PAUSED_REPAIR_MAX} log=${LOG_FILE || "off"}`,
453
+ );
454
+ } else {
455
+ notify(ctx, "Usage: /proxyguard [check|restart|status]", "warning");
456
+ }
457
+ },
458
+ });
459
+ }
package/package.json ADDED
@@ -0,0 +1,24 @@
1
+ {
2
+ "name": "pi-proxy-guard",
3
+ "version": "0.5.1",
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
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/pi-dal/pi-proxy-guard.git"
9
+ },
10
+ "type": "module",
11
+ "files": [
12
+ "extensions",
13
+ "README.md",
14
+ "LICENSE"
15
+ ],
16
+ "keywords": [
17
+ "pi-package"
18
+ ],
19
+ "pi": {
20
+ "extensions": [
21
+ "./extensions/proxy-guard.ts"
22
+ ]
23
+ }
24
+ }