pi-freeflow 1.9.2 → 1.9.4
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/CHANGELOG.md +18 -0
- package/README.md +2 -2
- package/package.json +2 -4
- package/src/client.ts +323 -313
- package/src/commands.ts +24 -23
- package/src/proxy.ts +7 -4
- package/src/relay.ts +4 -17
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,24 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to pi-freeflow. Public, user-visible behavior only.
|
|
4
4
|
|
|
5
|
+
## 1.9.4 - 2026-09-02
|
|
6
|
+
|
|
7
|
+
### Dependencies
|
|
8
|
+
- **Zero runtime dependencies.** Removed `undici@8.10.0` — `pi` (`0.84.4`) and `omp` (`18.1.3`) already bundle `undici` 6.x/7.x and expose `global fetch` with keep-alive pooling. `relayFetch` and proxy now use `global fetch` directly (`src/relay.ts` `Agent` + `canUseCustomDispatcher` + `dispatcher: agent` removed; `src/proxy.ts` dispatcher removed). Keeps thin `11.3k` + `298 tests` + `0 deps` compatible directly with `reference/pi` + `reference/oh-my-pi`.
|
|
9
|
+
|
|
10
|
+
### Validation
|
|
11
|
+
- `npx tsc --noEmit` clean, `npm test 298/298` on **Windows** (`omp/18.1.3`, `pi 0.84.4`) and **Linux `acerblue-local`** (`Ubuntu 6.8.0-138`, `node v22.23.2`, `pi 0.84.4`) via `/tmp/pi-freeflow-validation`. **`macOS not tested`** this cycle.
|
|
12
|
+
|
|
13
|
+
## 1.9.3 - 2026-09-02
|
|
14
|
+
|
|
15
|
+
### Fixes
|
|
16
|
+
- **Windows console flood fixed.** Two Windows-only helpers flashed a visible `conhost`/`cmd` window on every daemon probe: `netstat -ano | findstr :28180` / `taskkill` in the stale-daemon replace path and `spawn(omp|npm, shell:true)` for `/freeflow update`. Both now use `windowsHide: true` (no-op on Linux/macOS) and `spawnWithProgress` was refactored to `Promise.withResolvers` to satisfy `ts-promise-with-resolvers`. Idle `pi` no longer spawns many visible consoles even after closing the terminal (detached daemon at `127.0.0.1:28180` survives by design; `beatOnce` 10s heartbeat now throttled 2s via `lastSpawnAt`).
|
|
17
|
+
- Daemon spawn now throttled per-process (2s) as a storm guard when `28180` is contended or blocked; the `ensuring` guard + `waitForReady 5s` already prevented tight loops.
|
|
18
|
+
|
|
19
|
+
### Validation
|
|
20
|
+
- `npx tsc --noEmit` clean, `npm test 279/279` on **Windows** (`omp/18.1.3`, `pi 0.84.4`) and **Linux `acerblue-local`** (`Ubuntu 6.8.0-138`, `node v22.23.2`, `pi 0.84.4`) via `/tmp/pi-freeflow-validation`. **`macOS not tested`** this cycle.
|
|
21
|
+
- Reporter `LOYINuts` issue #3 (`pi idle creates many sessions → force reboot`) — `grep -r rtk src` ∅ confirms `rtk` is an external global skill (`~/.agents/skills/rtk` → `Command::new("cmd")` without `CREATE_NO_WINDOW`), not `pi-freeflow`. After this fix, closing the terminal no longer leaves flashing zombies; kill via `netstat -ano | findstr :28180` → `taskkill /F /PID` or `/freeflow kill`.
|
|
22
|
+
|
|
5
23
|
## 1.9.2 - 2026-08-31
|
|
6
24
|
|
|
7
25
|
### Fixes
|
package/README.md
CHANGED
|
@@ -261,7 +261,7 @@ Log rotation at 10MB. Clean, parseable, real-time HTTP lifecycle tracking.
|
|
|
261
261
|
|
|
262
262
|
### Design
|
|
263
263
|
|
|
264
|
-
This package stays thin. It ships three things: a model catalog, a relay proxy, and a log. There is no build step.
|
|
264
|
+
This package stays thin. It ships three things: a model catalog, a relay proxy, and a log. There is no build step. Zero runtime dependencies — uses native Node.js global fetch. Thinking and prompt normalization stay with the host (`pi-ai`).
|
|
265
265
|
|
|
266
266
|
Current size: about 11.3k lines including tests. The full suite (sandboxed, mocked network) and typecheck pass before every release — see CHANGELOG.md.
|
|
267
267
|
|
|
@@ -361,7 +361,7 @@ test/
|
|
|
361
361
|
|
|
362
362
|
#### Guidelines
|
|
363
363
|
|
|
364
|
-
- **Stay thin.**
|
|
364
|
+
- **Stay thin.** Zero runtime dependencies, no build step. If it belongs in the host (`pi-ai`), don't add it here.
|
|
365
365
|
- **Test what you touch.** Every `src/*.ts` has a matching `test/*.test.ts`. Add or update tests for your change.
|
|
366
366
|
- **Keep model IDs clean.** Slash-free, colon-free aliases for CLI compatibility. See existing patterns in `models.ts`.
|
|
367
367
|
- **One concern per PR.** Bug fix? One PR. New relay platform? Separate PR. Easier to review, faster to merge.
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-freeflow",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "1.9.
|
|
4
|
+
"version": "1.9.4",
|
|
5
5
|
"description": "Thin provider for OMP/Pi — model list + dumb relay proxy + log; host pi-ai owns thinking/normalization",
|
|
6
6
|
"main": "extensions/index.ts",
|
|
7
7
|
"types": "src/index.ts",
|
|
@@ -57,7 +57,5 @@
|
|
|
57
57
|
"typescript": "^5.8.2",
|
|
58
58
|
"vitepress": "^1.6.4"
|
|
59
59
|
},
|
|
60
|
-
"dependencies": {
|
|
61
|
-
"undici": "^8.10.0"
|
|
62
|
-
}
|
|
60
|
+
"dependencies": {}
|
|
63
61
|
}
|
package/src/client.ts
CHANGED
|
@@ -1,313 +1,323 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Client-side daemon lifecycle for pi-freeflow.
|
|
3
|
-
*
|
|
4
|
-
* Every OMP/Pi session is a client. It attaches to the shared detached daemon
|
|
5
|
-
* at 127.0.0.1:28180 (or spawns one if none is alive), registers a lease, and
|
|
6
|
-
* renews it with a heartbeat while the session lives. When the session ends
|
|
7
|
-
* the heartbeat stops; the daemon drops the lease after its TTL and retires
|
|
8
|
-
* once no client holds a live lease and no request has been proxied recently.
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
|
-
import { randomUUID } from "node:crypto";
|
|
12
|
-
import { spawn } from "node:child_process";
|
|
13
|
-
import path from "node:path";
|
|
14
|
-
import { fileURLToPath } from "node:url";
|
|
15
|
-
import type * as http from "node:http";
|
|
16
|
-
import {
|
|
17
|
-
DAEMON_HEARTBEAT_MS,
|
|
18
|
-
DAEMON_HEARTBEAT_MS_ENV,
|
|
19
|
-
DAEMON_READY_TIMEOUT_MS,
|
|
20
|
-
DAEMON_SPAWN_ENV,
|
|
21
|
-
HOST,
|
|
22
|
-
LEGACY_PORT,
|
|
23
|
-
NO_KILL_ENV,
|
|
24
|
-
PKG_VERSION,
|
|
25
|
-
PORT,
|
|
26
|
-
} from "./config.ts";
|
|
27
|
-
import { logInfo, logWarn } from "./logger.ts";
|
|
28
|
-
import {
|
|
29
|
-
getDaemonHealth,
|
|
30
|
-
getDaemonVersion,
|
|
31
|
-
isProxyAlive,
|
|
32
|
-
killPortHolder,
|
|
33
|
-
startProxy,
|
|
34
|
-
} from "./proxy.ts";
|
|
35
|
-
import { compareVersions } from "./update-checker.ts";
|
|
36
|
-
|
|
37
|
-
const CLIENT_ID = randomUUID();
|
|
38
|
-
|
|
39
|
-
let attachedPort = 0;
|
|
40
|
-
let heartbeatTimer: ReturnType<typeof setInterval> | null = null;
|
|
41
|
-
let heartbeatPort = 0;
|
|
42
|
-
let ensuring = false;
|
|
43
|
-
let fallbackServer: http.Server | null = null;
|
|
44
|
-
|
|
45
|
-
function isBunRuntime(): boolean {
|
|
46
|
-
return typeof (process.versions as unknown as Record<string, string>).bun === "string";
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
function daemonScriptPath(): string {
|
|
50
|
-
return path.join(path.dirname(fileURLToPath(import.meta.url)), "daemon.ts");
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
function getHeartbeatMs(): number {
|
|
54
|
-
const raw = process.env[DAEMON_HEARTBEAT_MS_ENV];
|
|
55
|
-
if (raw) {
|
|
56
|
-
const parsed = Number(raw);
|
|
57
|
-
if (Number.isFinite(parsed) && parsed > 0) return parsed;
|
|
58
|
-
}
|
|
59
|
-
return DAEMON_HEARTBEAT_MS;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
function isSpawnEnabled(): boolean {
|
|
63
|
-
return process.env[DAEMON_SPAWN_ENV] !== "0";
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
type ControlResult = "ok" | "legacy" | "gone";
|
|
67
|
-
|
|
68
|
-
async function controlCall(
|
|
69
|
-
port: number,
|
|
70
|
-
endpoint: string,
|
|
71
|
-
payload: Record<string, string>,
|
|
72
|
-
): Promise<ControlResult> {
|
|
73
|
-
try {
|
|
74
|
-
const res = await fetch(`http://${HOST}:${port}${endpoint}`, {
|
|
75
|
-
method: "POST",
|
|
76
|
-
headers: { "content-type": "application/json" },
|
|
77
|
-
body: JSON.stringify(payload),
|
|
78
|
-
signal: AbortSignal.timeout(1500),
|
|
79
|
-
});
|
|
80
|
-
return res.ok ? "ok" : "legacy";
|
|
81
|
-
} catch {
|
|
82
|
-
return "gone";
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
function startHeartbeat(port: number): void {
|
|
87
|
-
stopHeartbeatInternal();
|
|
88
|
-
heartbeatPort = port;
|
|
89
|
-
const ms = getHeartbeatMs();
|
|
90
|
-
heartbeatTimer = setInterval(() => {
|
|
91
|
-
void beatOnce(port);
|
|
92
|
-
}, ms);
|
|
93
|
-
try {
|
|
94
|
-
heartbeatTimer.unref();
|
|
95
|
-
} catch {}
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
function stopHeartbeatInternal(): void {
|
|
99
|
-
if (heartbeatTimer !== null) {
|
|
100
|
-
clearInterval(heartbeatTimer);
|
|
101
|
-
heartbeatTimer = null;
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
export function stopHeartbeat(): void {
|
|
106
|
-
const port = heartbeatPort;
|
|
107
|
-
stopHeartbeatInternal();
|
|
108
|
-
heartbeatPort = 0;
|
|
109
|
-
if (port) {
|
|
110
|
-
void controlCall(port, "/_client/detach", { id: CLIENT_ID });
|
|
111
|
-
}
|
|
112
|
-
if (fallbackServer) {
|
|
113
|
-
try {
|
|
114
|
-
fallbackServer.close();
|
|
115
|
-
} catch {}
|
|
116
|
-
fallbackServer = null;
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
async function beatOnce(port: number): Promise<void> {
|
|
121
|
-
if (ensuring) return;
|
|
122
|
-
const result = await controlCall(port, "/_client/heartbeat", { id: CLIENT_ID });
|
|
123
|
-
if (result === "gone") {
|
|
124
|
-
void ensureDaemon();
|
|
125
|
-
}
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
async function attachTo(port: number): Promise<void> {
|
|
129
|
-
attachedPort = port;
|
|
130
|
-
const result = await controlCall(port, "/_client/attach", { id: CLIENT_ID });
|
|
131
|
-
if (result === "gone") {
|
|
132
|
-
return;
|
|
133
|
-
}
|
|
134
|
-
startHeartbeat(port);
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
async function shouldReplaceDaemon(port: number, remoteVer: string): Promise<boolean> {
|
|
138
|
-
if (NO_KILL_ENV && process.env[NO_KILL_ENV] === "1") {
|
|
139
|
-
logInfo(
|
|
140
|
-
`Reusing existing pi-freeflow proxy daemon on http://${HOST}:${port} (replacement disabled by env)`,
|
|
141
|
-
);
|
|
142
|
-
return false;
|
|
143
|
-
}
|
|
144
|
-
if (compareVersions(remoteVer, PKG_VERSION) > 0) {
|
|
145
|
-
logInfo(
|
|
146
|
-
`Reusing existing pi-freeflow proxy daemon on http://${HOST}:${port} (newer daemon v${remoteVer} left running)`,
|
|
147
|
-
);
|
|
148
|
-
return false;
|
|
149
|
-
}
|
|
150
|
-
const health = await getDaemonHealth(port);
|
|
151
|
-
if (health === null || health.activeRequests === undefined) {
|
|
152
|
-
logInfo(
|
|
153
|
-
`Reusing existing pi-freeflow proxy daemon on http://${HOST}:${port} (cannot verify usage — leaving the running daemon untouched)`,
|
|
154
|
-
);
|
|
155
|
-
return false;
|
|
156
|
-
}
|
|
157
|
-
if (health.activeRequests > 0) {
|
|
158
|
-
logInfo(
|
|
159
|
-
`Reusing existing pi-freeflow proxy daemon on http://${HOST}:${port} (${health.activeRequests} active request${health.activeRequests === 1 ? "" : "s"} — not interrupted)`,
|
|
160
|
-
);
|
|
161
|
-
return false;
|
|
162
|
-
}
|
|
163
|
-
return true;
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
async function killStaleDaemon(
|
|
167
|
-
port: number,
|
|
168
|
-
remoteVer: string,
|
|
169
|
-
what: string,
|
|
170
|
-
): Promise<boolean> {
|
|
171
|
-
if (!(await shouldReplaceDaemon(port, remoteVer))) return false;
|
|
172
|
-
logWarn(`stale ${what} v${remoteVer} on :${port} (need v${PKG_VERSION}) — replacing`, {
|
|
173
|
-
remoteVer,
|
|
174
|
-
expected: PKG_VERSION,
|
|
175
|
-
});
|
|
176
|
-
await killPortHolder(port);
|
|
177
|
-
for (let i = 0; i < 10; i++) {
|
|
178
|
-
await new Promise<void>((r) => setTimeout(r, 200));
|
|
179
|
-
if (!(await isProxyAlive(port))) return true;
|
|
180
|
-
}
|
|
181
|
-
logInfo(
|
|
182
|
-
`Reusing existing pi-freeflow proxy daemon on http://${HOST}:${port} (stale kill did not free port)`,
|
|
183
|
-
);
|
|
184
|
-
return false;
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
async function
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
return
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
const
|
|
255
|
-
if (
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Client-side daemon lifecycle for pi-freeflow.
|
|
3
|
+
*
|
|
4
|
+
* Every OMP/Pi session is a client. It attaches to the shared detached daemon
|
|
5
|
+
* at 127.0.0.1:28180 (or spawns one if none is alive), registers a lease, and
|
|
6
|
+
* renews it with a heartbeat while the session lives. When the session ends
|
|
7
|
+
* the heartbeat stops; the daemon drops the lease after its TTL and retires
|
|
8
|
+
* once no client holds a live lease and no request has been proxied recently.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { randomUUID } from "node:crypto";
|
|
12
|
+
import { spawn } from "node:child_process";
|
|
13
|
+
import path from "node:path";
|
|
14
|
+
import { fileURLToPath } from "node:url";
|
|
15
|
+
import type * as http from "node:http";
|
|
16
|
+
import {
|
|
17
|
+
DAEMON_HEARTBEAT_MS,
|
|
18
|
+
DAEMON_HEARTBEAT_MS_ENV,
|
|
19
|
+
DAEMON_READY_TIMEOUT_MS,
|
|
20
|
+
DAEMON_SPAWN_ENV,
|
|
21
|
+
HOST,
|
|
22
|
+
LEGACY_PORT,
|
|
23
|
+
NO_KILL_ENV,
|
|
24
|
+
PKG_VERSION,
|
|
25
|
+
PORT,
|
|
26
|
+
} from "./config.ts";
|
|
27
|
+
import { logInfo, logWarn } from "./logger.ts";
|
|
28
|
+
import {
|
|
29
|
+
getDaemonHealth,
|
|
30
|
+
getDaemonVersion,
|
|
31
|
+
isProxyAlive,
|
|
32
|
+
killPortHolder,
|
|
33
|
+
startProxy,
|
|
34
|
+
} from "./proxy.ts";
|
|
35
|
+
import { compareVersions } from "./update-checker.ts";
|
|
36
|
+
|
|
37
|
+
const CLIENT_ID = randomUUID();
|
|
38
|
+
|
|
39
|
+
let attachedPort = 0;
|
|
40
|
+
let heartbeatTimer: ReturnType<typeof setInterval> | null = null;
|
|
41
|
+
let heartbeatPort = 0;
|
|
42
|
+
let ensuring = false;
|
|
43
|
+
let fallbackServer: http.Server | null = null;
|
|
44
|
+
|
|
45
|
+
function isBunRuntime(): boolean {
|
|
46
|
+
return typeof (process.versions as unknown as Record<string, string>).bun === "string";
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function daemonScriptPath(): string {
|
|
50
|
+
return path.join(path.dirname(fileURLToPath(import.meta.url)), "daemon.ts");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function getHeartbeatMs(): number {
|
|
54
|
+
const raw = process.env[DAEMON_HEARTBEAT_MS_ENV];
|
|
55
|
+
if (raw) {
|
|
56
|
+
const parsed = Number(raw);
|
|
57
|
+
if (Number.isFinite(parsed) && parsed > 0) return parsed;
|
|
58
|
+
}
|
|
59
|
+
return DAEMON_HEARTBEAT_MS;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function isSpawnEnabled(): boolean {
|
|
63
|
+
return process.env[DAEMON_SPAWN_ENV] !== "0";
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
type ControlResult = "ok" | "legacy" | "gone";
|
|
67
|
+
|
|
68
|
+
async function controlCall(
|
|
69
|
+
port: number,
|
|
70
|
+
endpoint: string,
|
|
71
|
+
payload: Record<string, string>,
|
|
72
|
+
): Promise<ControlResult> {
|
|
73
|
+
try {
|
|
74
|
+
const res = await fetch(`http://${HOST}:${port}${endpoint}`, {
|
|
75
|
+
method: "POST",
|
|
76
|
+
headers: { "content-type": "application/json" },
|
|
77
|
+
body: JSON.stringify(payload),
|
|
78
|
+
signal: AbortSignal.timeout(1500),
|
|
79
|
+
});
|
|
80
|
+
return res.ok ? "ok" : "legacy";
|
|
81
|
+
} catch {
|
|
82
|
+
return "gone";
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function startHeartbeat(port: number): void {
|
|
87
|
+
stopHeartbeatInternal();
|
|
88
|
+
heartbeatPort = port;
|
|
89
|
+
const ms = getHeartbeatMs();
|
|
90
|
+
heartbeatTimer = setInterval(() => {
|
|
91
|
+
void beatOnce(port);
|
|
92
|
+
}, ms);
|
|
93
|
+
try {
|
|
94
|
+
heartbeatTimer.unref();
|
|
95
|
+
} catch {}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function stopHeartbeatInternal(): void {
|
|
99
|
+
if (heartbeatTimer !== null) {
|
|
100
|
+
clearInterval(heartbeatTimer);
|
|
101
|
+
heartbeatTimer = null;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function stopHeartbeat(): void {
|
|
106
|
+
const port = heartbeatPort;
|
|
107
|
+
stopHeartbeatInternal();
|
|
108
|
+
heartbeatPort = 0;
|
|
109
|
+
if (port) {
|
|
110
|
+
void controlCall(port, "/_client/detach", { id: CLIENT_ID });
|
|
111
|
+
}
|
|
112
|
+
if (fallbackServer) {
|
|
113
|
+
try {
|
|
114
|
+
fallbackServer.close();
|
|
115
|
+
} catch {}
|
|
116
|
+
fallbackServer = null;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function beatOnce(port: number): Promise<void> {
|
|
121
|
+
if (ensuring) return;
|
|
122
|
+
const result = await controlCall(port, "/_client/heartbeat", { id: CLIENT_ID });
|
|
123
|
+
if (result === "gone") {
|
|
124
|
+
void ensureDaemon();
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function attachTo(port: number): Promise<void> {
|
|
129
|
+
attachedPort = port;
|
|
130
|
+
const result = await controlCall(port, "/_client/attach", { id: CLIENT_ID });
|
|
131
|
+
if (result === "gone") {
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
startHeartbeat(port);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function shouldReplaceDaemon(port: number, remoteVer: string): Promise<boolean> {
|
|
138
|
+
if (NO_KILL_ENV && process.env[NO_KILL_ENV] === "1") {
|
|
139
|
+
logInfo(
|
|
140
|
+
`Reusing existing pi-freeflow proxy daemon on http://${HOST}:${port} (replacement disabled by env)`,
|
|
141
|
+
);
|
|
142
|
+
return false;
|
|
143
|
+
}
|
|
144
|
+
if (compareVersions(remoteVer, PKG_VERSION) > 0) {
|
|
145
|
+
logInfo(
|
|
146
|
+
`Reusing existing pi-freeflow proxy daemon on http://${HOST}:${port} (newer daemon v${remoteVer} left running)`,
|
|
147
|
+
);
|
|
148
|
+
return false;
|
|
149
|
+
}
|
|
150
|
+
const health = await getDaemonHealth(port);
|
|
151
|
+
if (health === null || health.activeRequests === undefined) {
|
|
152
|
+
logInfo(
|
|
153
|
+
`Reusing existing pi-freeflow proxy daemon on http://${HOST}:${port} (cannot verify usage — leaving the running daemon untouched)`,
|
|
154
|
+
);
|
|
155
|
+
return false;
|
|
156
|
+
}
|
|
157
|
+
if (health.activeRequests > 0) {
|
|
158
|
+
logInfo(
|
|
159
|
+
`Reusing existing pi-freeflow proxy daemon on http://${HOST}:${port} (${health.activeRequests} active request${health.activeRequests === 1 ? "" : "s"} — not interrupted)`,
|
|
160
|
+
);
|
|
161
|
+
return false;
|
|
162
|
+
}
|
|
163
|
+
return true;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async function killStaleDaemon(
|
|
167
|
+
port: number,
|
|
168
|
+
remoteVer: string,
|
|
169
|
+
what: string,
|
|
170
|
+
): Promise<boolean> {
|
|
171
|
+
if (!(await shouldReplaceDaemon(port, remoteVer))) return false;
|
|
172
|
+
logWarn(`stale ${what} v${remoteVer} on :${port} (need v${PKG_VERSION}) — replacing`, {
|
|
173
|
+
remoteVer,
|
|
174
|
+
expected: PKG_VERSION,
|
|
175
|
+
});
|
|
176
|
+
await killPortHolder(port);
|
|
177
|
+
for (let i = 0; i < 10; i++) {
|
|
178
|
+
await new Promise<void>((r) => setTimeout(r, 200));
|
|
179
|
+
if (!(await isProxyAlive(port))) return true;
|
|
180
|
+
}
|
|
181
|
+
logInfo(
|
|
182
|
+
`Reusing existing pi-freeflow proxy daemon on http://${HOST}:${port} (stale kill did not free port)`,
|
|
183
|
+
);
|
|
184
|
+
return false;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
let lastSpawnAt = 0;
|
|
188
|
+
const SPAWN_THROTTLE_MS = 2_000;
|
|
189
|
+
|
|
190
|
+
function spawnDaemonProcess(): void {
|
|
191
|
+
const now = Date.now();
|
|
192
|
+
if (now - lastSpawnAt < SPAWN_THROTTLE_MS) {
|
|
193
|
+
logWarn("daemon spawn throttled — recent spawn still pending");
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
lastSpawnAt = now;
|
|
197
|
+
const script = daemonScriptPath();
|
|
198
|
+
const args = isBunRuntime() ? [script] : ["--experimental-strip-types", script];
|
|
199
|
+
try {
|
|
200
|
+
const child = spawn(process.execPath, args, {
|
|
201
|
+
detached: true,
|
|
202
|
+
stdio: "ignore",
|
|
203
|
+
windowsHide: true,
|
|
204
|
+
});
|
|
205
|
+
child.unref();
|
|
206
|
+
child.on("error", (err) => {
|
|
207
|
+
logWarn("daemon spawn failed", { error: String(err) });
|
|
208
|
+
});
|
|
209
|
+
} catch (e) {
|
|
210
|
+
logWarn("daemon spawn failed", { error: String(e) });
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async function waitForReady(port: number, timeoutMs: number): Promise<boolean> {
|
|
215
|
+
const deadline = Date.now() + timeoutMs;
|
|
216
|
+
while (Date.now() < deadline) {
|
|
217
|
+
if (await isProxyAlive(port)) return true;
|
|
218
|
+
await new Promise<void>((r) => setTimeout(r, 200));
|
|
219
|
+
}
|
|
220
|
+
return false;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async function probeAndMaybeReplace(
|
|
224
|
+
port: number,
|
|
225
|
+
label: string,
|
|
226
|
+
): Promise<number | null> {
|
|
227
|
+
if (!(await isProxyAlive(port))) return null;
|
|
228
|
+
const ver = await getDaemonVersion(port);
|
|
229
|
+
if (ver !== null && ver !== PKG_VERSION) {
|
|
230
|
+
if (await killStaleDaemon(port, ver, label)) {
|
|
231
|
+
return null;
|
|
232
|
+
}
|
|
233
|
+
if (await isProxyAlive(port)) return port;
|
|
234
|
+
return null;
|
|
235
|
+
}
|
|
236
|
+
return port;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Ensure a proxy daemon is running and attach this client to it.
|
|
241
|
+
* Returns the port the caller should use for its ProviderConfig.
|
|
242
|
+
*/
|
|
243
|
+
export async function ensureDaemon(): Promise<number> {
|
|
244
|
+
if (ensuring) return attachedPort || PORT;
|
|
245
|
+
ensuring = true;
|
|
246
|
+
try {
|
|
247
|
+
const primary = await probeAndMaybeReplace(PORT, "proxy daemon");
|
|
248
|
+
if (primary !== null) {
|
|
249
|
+
await attachTo(primary);
|
|
250
|
+
return primary;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
if (PORT !== LEGACY_PORT) {
|
|
254
|
+
const legacy = await probeAndMaybeReplace(LEGACY_PORT, "legacy proxy daemon");
|
|
255
|
+
if (legacy !== null) {
|
|
256
|
+
await attachTo(legacy);
|
|
257
|
+
return legacy;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if (isSpawnEnabled()) {
|
|
262
|
+
spawnDaemonProcess();
|
|
263
|
+
const ready = await waitForReady(PORT, DAEMON_READY_TIMEOUT_MS);
|
|
264
|
+
if (ready) {
|
|
265
|
+
const ver = await getDaemonVersion(PORT);
|
|
266
|
+
if (ver !== null && ver !== PKG_VERSION) {
|
|
267
|
+
if (await killStaleDaemon(PORT, ver, "proxy daemon")) {
|
|
268
|
+
spawnDaemonProcess();
|
|
269
|
+
const retryReady = await waitForReady(PORT, DAEMON_READY_TIMEOUT_MS);
|
|
270
|
+
if (retryReady) {
|
|
271
|
+
await attachTo(PORT);
|
|
272
|
+
return PORT;
|
|
273
|
+
}
|
|
274
|
+
} else if (await isProxyAlive(PORT)) {
|
|
275
|
+
await attachTo(PORT);
|
|
276
|
+
return PORT;
|
|
277
|
+
}
|
|
278
|
+
} else {
|
|
279
|
+
await attachTo(PORT);
|
|
280
|
+
return PORT;
|
|
281
|
+
}
|
|
282
|
+
} else {
|
|
283
|
+
logWarn("daemon spawn did not become ready — is the port blocked?");
|
|
284
|
+
}
|
|
285
|
+
return PORT;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
try {
|
|
289
|
+
const r = await startProxy();
|
|
290
|
+
if (r.server) fallbackServer = r.server;
|
|
291
|
+
const port = r.port;
|
|
292
|
+
await attachTo(port);
|
|
293
|
+
return port;
|
|
294
|
+
} catch (e) {
|
|
295
|
+
logWarn("in-process fallback bind failed", { error: String(e) });
|
|
296
|
+
return PORT;
|
|
297
|
+
}
|
|
298
|
+
} finally {
|
|
299
|
+
ensuring = false;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
export function getClientPort(): number {
|
|
304
|
+
return attachedPort || PORT;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
export function getClientId(): string {
|
|
308
|
+
return CLIENT_ID;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
export function _resetClientForTest(): void {
|
|
312
|
+
stopHeartbeatInternal();
|
|
313
|
+
heartbeatPort = 0;
|
|
314
|
+
attachedPort = 0;
|
|
315
|
+
ensuring = false;
|
|
316
|
+
lastSpawnAt = 0;
|
|
317
|
+
if (fallbackServer) {
|
|
318
|
+
try {
|
|
319
|
+
fallbackServer.close();
|
|
320
|
+
} catch {}
|
|
321
|
+
fallbackServer = null;
|
|
322
|
+
}
|
|
323
|
+
}
|
package/src/commands.ts
CHANGED
|
@@ -69,30 +69,31 @@ function spawnWithProgress(
|
|
|
69
69
|
args: string[],
|
|
70
70
|
ctx: ExtensionContext,
|
|
71
71
|
): Promise<number> {
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
});
|
|
90
|
-
child.on("close", (code: number | null) => resolve(code ?? 0));
|
|
91
|
-
} catch (e) {
|
|
92
|
-
ctx.ui.notify(`spawn ${cmd} failed: ${(e as Error).message}`, "warning");
|
|
72
|
+
const { promise, resolve } = Promise.withResolvers<number>();
|
|
73
|
+
try {
|
|
74
|
+
const child = spawn(cmd, args, {
|
|
75
|
+
shell: process.platform === "win32",
|
|
76
|
+
stdio: "pipe",
|
|
77
|
+
windowsHide: true,
|
|
78
|
+
});
|
|
79
|
+
child.stdout?.on("data", (d: Buffer) => {
|
|
80
|
+
const s = String(d).trim();
|
|
81
|
+
if (s) ctx.ui.notify(s, "info");
|
|
82
|
+
});
|
|
83
|
+
child.stderr?.on("data", (d: Buffer) => {
|
|
84
|
+
const s = String(d).trim();
|
|
85
|
+
if (s) ctx.ui.notify(s, "info");
|
|
86
|
+
});
|
|
87
|
+
child.on("error", (err: Error) => {
|
|
88
|
+
ctx.ui.notify(`spawn ${cmd} failed: ${err.message}`, "warning");
|
|
93
89
|
resolve(1);
|
|
94
|
-
}
|
|
95
|
-
|
|
90
|
+
});
|
|
91
|
+
child.on("close", (code: number | null) => resolve(code ?? 0));
|
|
92
|
+
} catch (e) {
|
|
93
|
+
ctx.ui.notify(`spawn ${cmd} failed: ${(e as Error).message}`, "warning");
|
|
94
|
+
resolve(1);
|
|
95
|
+
}
|
|
96
|
+
return promise;
|
|
96
97
|
}
|
|
97
98
|
|
|
98
99
|
/**
|
package/src/proxy.ts
CHANGED
|
@@ -32,7 +32,7 @@ import { isDebugEnabled, log } from "./logger.ts";
|
|
|
32
32
|
import { KILO_MODEL_IDS, resolveCanonicalModelId } from "./models.ts";
|
|
33
33
|
// normalize removed — host pi-ai already normalizes thinking/reasoning before proxy
|
|
34
34
|
import { checkRateLimit } from "./rate-limiter.ts";
|
|
35
|
-
import {
|
|
35
|
+
import { relayFetch } from "./relay.ts";
|
|
36
36
|
import { getActiveRelayState } from "./relay-state.ts";
|
|
37
37
|
import { pipeUpstreamStream } from "./stream-pipe.ts";
|
|
38
38
|
import type { Upstream } from "./types.ts";
|
|
@@ -182,7 +182,11 @@ export async function killPortHolder(port: number): Promise<boolean> {
|
|
|
182
182
|
try {
|
|
183
183
|
if (process.platform === "win32") {
|
|
184
184
|
try {
|
|
185
|
-
const out = execSync(`netstat -ano | findstr :${port}`, {
|
|
185
|
+
const out = execSync(`netstat -ano | findstr :${port}`, {
|
|
186
|
+
encoding: "utf8",
|
|
187
|
+
timeout: 3000,
|
|
188
|
+
windowsHide: true,
|
|
189
|
+
}) as string;
|
|
186
190
|
for (const line of out.split("\n")) {
|
|
187
191
|
if (!line.includes("LISTENING")) continue;
|
|
188
192
|
if (!line.includes(`:${port}`)) continue;
|
|
@@ -191,7 +195,7 @@ export async function killPortHolder(port: number): Promise<boolean> {
|
|
|
191
195
|
if (!pid || !/^\d+$/.test(pid)) continue;
|
|
192
196
|
// Windows netstat report: 127.0.0.1:38180 ... LISTENING/PID
|
|
193
197
|
if (Number(pid) === process.pid) continue; // never self-kill
|
|
194
|
-
execSync(`taskkill /F /PID ${pid}`, { timeout: 3000, stdio: "ignore" });
|
|
198
|
+
execSync(`taskkill /F /PID ${pid}`, { timeout: 3000, stdio: "ignore", windowsHide: true });
|
|
195
199
|
return true;
|
|
196
200
|
}
|
|
197
201
|
} catch {}
|
|
@@ -641,7 +645,6 @@ export function startProxy(
|
|
|
641
645
|
headers: fwd,
|
|
642
646
|
body: directBody.length > 0 ? directBody : undefined,
|
|
643
647
|
signal: controller.signal,
|
|
644
|
-
...(canUseCustomDispatcher ? { dispatcher: agent } : {}),
|
|
645
648
|
} as unknown as RequestInit);
|
|
646
649
|
clearTimeout(timeoutId);
|
|
647
650
|
res.off("close", onClientClose);
|
package/src/relay.ts
CHANGED
|
@@ -4,20 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
import { randomUUID } from "node:crypto";
|
|
6
6
|
import { Buffer } from "node:buffer";
|
|
7
|
-
import { Agent } from "undici";
|
|
8
7
|
import { isDebugEnabled, log } from "./logger.ts";
|
|
9
|
-
|
|
10
|
-
export const agent = new Agent({ keepAliveTimeout: 30_000 });
|
|
11
|
-
/**
|
|
12
|
-
* Node's global fetch runs on the node-process-bundled undici. Node 22/23
|
|
13
|
-
* bundle undici 6.x/7.x whose dispatcher interface rejects an npm-undici 8.x
|
|
14
|
-
* Agent passed as `dispatcher` (`invalid onRequestStart method`). Only attach
|
|
15
|
-
* the custom dispatcher when the bundled undici major matches (Node 25+);
|
|
16
|
-
* otherwise fall back to the built-in global dispatcher, which still pools
|
|
17
|
-
* keep-alive connections, so the relay proxy works on Node 22/23 too.
|
|
18
|
-
*/
|
|
19
|
-
const bundledUndiciMajor = Number((process.versions.undici ?? "0").split(".")[0]);
|
|
20
|
-
export const canUseCustomDispatcher = bundledUndiciMajor >= 8;
|
|
21
8
|
import {
|
|
22
9
|
getActiveRelayState,
|
|
23
10
|
getOrderedRelayUrls,
|
|
@@ -68,7 +55,7 @@ export async function relayFetch(
|
|
|
68
55
|
|
|
69
56
|
if (!relayState.enabled) {
|
|
70
57
|
log("debug", `relayFetch: direct (relay disabled) -> ${url}`, undefined, rid);
|
|
71
|
-
return fetch(url,
|
|
58
|
+
return fetch(url, opts as unknown as RequestInit);
|
|
72
59
|
}
|
|
73
60
|
const candidates = getOrderedRelayUrls();
|
|
74
61
|
|
|
@@ -76,7 +63,7 @@ export async function relayFetch(
|
|
|
76
63
|
// Empty pool: skip straight to upstream instead of logging a misleading
|
|
77
64
|
// "relays bypassed/exhausted" WARN on every request.
|
|
78
65
|
log("debug", `relayFetch: direct (empty relay pool) -> ${url}`, undefined, rid);
|
|
79
|
-
return fetch(url,
|
|
66
|
+
return fetch(url, opts as unknown as RequestInit);
|
|
80
67
|
}
|
|
81
68
|
|
|
82
69
|
let lastResponse: Response | null = null;
|
|
@@ -145,7 +132,7 @@ export async function relayFetch(
|
|
|
145
132
|
}
|
|
146
133
|
|
|
147
134
|
const signal = opts.signal || AbortSignal.timeout(300_000);
|
|
148
|
-
const res = await fetch(targetUrl, { ...opts, headers, signal
|
|
135
|
+
const res = await fetch(targetUrl, { ...opts, headers, signal } as unknown as RequestInit);
|
|
149
136
|
const elapsed = ((Date.now() - attemptStart) / 1000).toFixed(1);
|
|
150
137
|
// Vercel 504 Gateway Timeout on heavy prompts (>50KB or >25s):
|
|
151
138
|
// Fast fallback directly to upstream instead of cycling through multiple 25s timeouts.
|
|
@@ -251,7 +238,7 @@ export async function relayFetch(
|
|
|
251
238
|
directHeaders.set("host", u.host);
|
|
252
239
|
directHeaders.set("x-request-id", rid);
|
|
253
240
|
|
|
254
|
-
const directRes = await fetch(url, { ...opts, headers: directHeaders
|
|
241
|
+
const directRes = await fetch(url, { ...opts, headers: directHeaders } as unknown as RequestInit);
|
|
255
242
|
// lastResponse holds an unread body that would otherwise leak its socket
|
|
256
243
|
// until GC; the salvage path below still needs it, so only cancel here.
|
|
257
244
|
lastResponse?.body?.cancel().catch(() => {});
|