pi-freeflow 1.44.0 → 1.45.0
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 +29 -0
- package/README.md +11 -13
- package/package.json +1 -1
- package/src/catalog.ts +38 -14
- package/src/client-recovery.ts +238 -0
- package/src/client-staging.ts +316 -0
- package/src/client.ts +117 -472
- package/src/cline-accounts.ts +26 -1223
- package/src/cline-device-auth.ts +5 -3
- package/src/cline-pool.ts +691 -0
- package/src/cline-roll.ts +642 -0
- package/src/commands-cline.ts +283 -0
- package/src/commands-relay.ts +808 -0
- package/src/commands-startup.ts +264 -0
- package/src/commands.ts +119 -1202
- package/src/config-paths.ts +254 -0
- package/src/config.ts +6 -687
- package/src/daemon.ts +16 -1
- package/src/deploy.ts +3 -1
- package/src/fingerprint-env.ts +334 -0
- package/src/fingerprint-tools.ts +348 -0
- package/src/index.ts +10 -6
- package/src/lane-translation.ts +263 -0
- package/src/models.ts +70 -112
- package/src/opencode-fingerprint.ts +29 -1310
- package/src/opencode-identity.ts +464 -0
- package/src/proxy-lanes.ts +1125 -0
- package/src/proxy-ports.ts +317 -0
- package/src/proxy.ts +74 -1385
- package/src/relay-codec.ts +365 -0
- package/src/relay-state.ts +10 -367
- package/src/relay.ts +78 -31
- package/src/sse-aggregator.ts +659 -0
- package/src/stream-cloak.ts +882 -0
- package/src/stream-pipe.ts +22 -871
- package/src/system-ca-fetch.ts +15 -4
- package/src/token.ts +7 -2
- package/src/tool-args.ts +10 -3
- package/src/tool-translation.ts +1 -236
- package/src/toolcall-audit.ts +20 -7
- package/src/update-checker.ts +17 -1
- package/src/update-core.ts +5 -2
- package/src/updater.ts +8 -3
- package/src/upstream-health.ts +22 -2
- package/src/zen-sidecar.ts +1 -0
- package/src/zen-transport.ts +48 -18
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Port inspection, holder classification, and daemon process management.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import fs from "node:fs";
|
|
6
|
+
import { execSync } from "node:child_process";
|
|
7
|
+
import net from "node:net";
|
|
8
|
+
import { DEFAULT_PORT, HOST } from "./config.ts";
|
|
9
|
+
import { isDaemonIdentityBody } from "./health.ts";
|
|
10
|
+
import type { DaemonHealthSnapshot } from "./health.ts";
|
|
11
|
+
import { tokenAuthHeaders } from "./token.ts";
|
|
12
|
+
import { log } from "./logger.ts";
|
|
13
|
+
|
|
14
|
+
const DIGITS_ONLY_RE = /^\d+$/;
|
|
15
|
+
const SOCKET_INODE_RE = /^socket:\[(\d+)\]$/;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* True when a pi-freeflow daemon answers on `port`.
|
|
19
|
+
*
|
|
20
|
+
* Uses the unauthenticated `GET /health` identity shape (service + the port it
|
|
21
|
+
* is bound to) instead of "any 200 with JSON": a stranger bound to the port
|
|
22
|
+
* cannot claim those fields, and this probe runs before the client has a token
|
|
23
|
+
* to prove ownership with.
|
|
24
|
+
*/
|
|
25
|
+
export async function isProxyAlive(port: number, timeoutMs = 600): Promise<boolean> {
|
|
26
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) return false;
|
|
27
|
+
try {
|
|
28
|
+
const res = await fetch(`http://${HOST}:${port}/health`, {
|
|
29
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
30
|
+
});
|
|
31
|
+
if (!res.ok) return false;
|
|
32
|
+
return isDaemonIdentityBody(port, await res.json());
|
|
33
|
+
} catch {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* True when a TCP connection to the loopback port succeeds — something holds
|
|
40
|
+
* the port (our daemon, a stale daemon, or an unrelated process). A free port
|
|
41
|
+
* refuses the connection immediately; the dark-holder reclaim path uses this
|
|
42
|
+
* to tell "nothing bound" apart from "bound but unresponsive" (issue #52: the
|
|
43
|
+
* blind kill-and-poll cost ~2s on every cold start).
|
|
44
|
+
*/
|
|
45
|
+
export async function isPortBound(port: number, timeoutMs = 250): Promise<boolean> {
|
|
46
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) return false;
|
|
47
|
+
const { promise, resolve } = Promise.withResolvers<boolean>();
|
|
48
|
+
const socket = net.connect({ host: HOST, port });
|
|
49
|
+
let settled = false;
|
|
50
|
+
const settle = (value: boolean): void => {
|
|
51
|
+
if (settled) return;
|
|
52
|
+
settled = true;
|
|
53
|
+
socket.destroy();
|
|
54
|
+
resolve(value);
|
|
55
|
+
};
|
|
56
|
+
socket.setTimeout(timeoutMs, () => settle(false));
|
|
57
|
+
socket.once("connect", () => settle(true));
|
|
58
|
+
socket.once("error", () => settle(false));
|
|
59
|
+
return await promise;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
let activeRequests = 0;
|
|
63
|
+
|
|
64
|
+
/** Current in-flight proxied requests (guard for stale-daemon replacement). */
|
|
65
|
+
export function getActiveRequests(): number {
|
|
66
|
+
return activeRequests;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function incrementActiveRequests(): void {
|
|
70
|
+
activeRequests += 1;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function decrementActiveRequests(): void {
|
|
74
|
+
activeRequests -= 1;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Fetch the full health snapshot from a running daemon.
|
|
79
|
+
* `activeRequests` is undefined on daemons older than the busy-tracking
|
|
80
|
+
* feature (1.9.0) — callers treat that as "cannot verify usage".
|
|
81
|
+
* `sseDegraded`/`sseRate`/`lastBytesAt` are undefined on daemons older than
|
|
82
|
+
* the failed-SSE window — callers treat missing as "not degraded".
|
|
83
|
+
*/
|
|
84
|
+
export async function getDaemonHealth(
|
|
85
|
+
port: number,
|
|
86
|
+
): Promise<DaemonHealthSnapshot> {
|
|
87
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) return null;
|
|
88
|
+
try {
|
|
89
|
+
const res = await fetch(`http://${HOST}:${port}/_health`, {
|
|
90
|
+
signal: AbortSignal.timeout(2500),
|
|
91
|
+
headers: tokenAuthHeaders(),
|
|
92
|
+
});
|
|
93
|
+
if (!res.ok) return null;
|
|
94
|
+
const data: unknown = await res.json();
|
|
95
|
+
if (data && typeof data === "object") {
|
|
96
|
+
const v =
|
|
97
|
+
"version" in data && typeof data.version === "string" && data.version
|
|
98
|
+
? data.version
|
|
99
|
+
: "";
|
|
100
|
+
const ar = "activeRequests" in data ? data.activeRequests : undefined;
|
|
101
|
+
const sr = "sseRate" in data ? data.sseRate : undefined;
|
|
102
|
+
const sd = "sseDegraded" in data ? data.sseDegraded : undefined;
|
|
103
|
+
const lb = "lastBytesAt" in data ? data.lastBytesAt : undefined;
|
|
104
|
+
let zt: DaemonHealthSnapshot extends null ? never : NonNullable<DaemonHealthSnapshot>["zenTransport"] = undefined;
|
|
105
|
+
if ("zenTransport" in data && typeof data.zenTransport === "object" && data.zenTransport !== null) {
|
|
106
|
+
const rawZt = data.zenTransport as Record<string, unknown>;
|
|
107
|
+
if (
|
|
108
|
+
(rawZt.mode === "node" || rawZt.mode === "bun-sidecar") &&
|
|
109
|
+
(typeof rawZt.bunVersion === "string" || rawZt.bunVersion === null) &&
|
|
110
|
+
typeof rawZt.reason === "string"
|
|
111
|
+
) {
|
|
112
|
+
zt = {
|
|
113
|
+
mode: rawZt.mode,
|
|
114
|
+
bunVersion: rawZt.bunVersion,
|
|
115
|
+
reason: rawZt.reason,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return {
|
|
120
|
+
version: v,
|
|
121
|
+
activeRequests: typeof ar === "number" ? ar : undefined,
|
|
122
|
+
sseRate: typeof sr === "number" ? sr : undefined,
|
|
123
|
+
sseDegraded: typeof sd === "boolean" ? sd : undefined,
|
|
124
|
+
lastBytesAt: typeof lb === "number" ? lb : undefined,
|
|
125
|
+
zenTransport: zt,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
return null;
|
|
129
|
+
} catch {
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Version of the daemon on `port`; "" when alive but pre-version-field, null when not alive. */
|
|
135
|
+
export async function getDaemonVersion(port: number): Promise<string | null> {
|
|
136
|
+
const h = await getDaemonHealth(port);
|
|
137
|
+
return h ? h.version : null;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Linux fallback when neither lsof nor fuser exists: resolve the LISTEN
|
|
142
|
+
* socket inode for `port` via /proc/net/tcp{,6}, then find the owning pid by
|
|
143
|
+
* scanning /proc fds. Dependency-free, same-user only, never self-kills.
|
|
144
|
+
*/
|
|
145
|
+
export function killViaProcNet(port: number): boolean {
|
|
146
|
+
if (process.platform !== "linux") return false;
|
|
147
|
+
try {
|
|
148
|
+
const hexPort = port.toString(16).toUpperCase().padStart(4, "0");
|
|
149
|
+
const inodes = new Set<string>();
|
|
150
|
+
for (const table of ["/proc/net/tcp", "/proc/net/tcp6"]) {
|
|
151
|
+
let text = "";
|
|
152
|
+
try {
|
|
153
|
+
text = fs.readFileSync(table, "utf8");
|
|
154
|
+
} catch {
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
for (const line of text.split("\n").slice(1)) {
|
|
158
|
+
const cols = line.trim().split(/\s+/);
|
|
159
|
+
if (cols.length < 10) continue;
|
|
160
|
+
const addr = cols[1].split(":");
|
|
161
|
+
if (addr.length !== 2 || addr[1].toUpperCase() !== hexPort) continue;
|
|
162
|
+
if (cols[3] !== "0A") continue; // LISTEN only
|
|
163
|
+
inodes.add(cols[9]);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
if (inodes.size === 0) return false;
|
|
167
|
+
for (const pid of fs.readdirSync("/proc")) {
|
|
168
|
+
if (!DIGITS_ONLY_RE.test(pid) || Number(pid) === process.pid) continue;
|
|
169
|
+
let fds: string[];
|
|
170
|
+
try {
|
|
171
|
+
fds = fs.readdirSync(`/proc/${pid}/fd`);
|
|
172
|
+
} catch {
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
for (const fd of fds) {
|
|
176
|
+
let link = "";
|
|
177
|
+
try {
|
|
178
|
+
link = fs.readlinkSync(`/proc/${pid}/fd/${fd}`);
|
|
179
|
+
} catch {
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
const sock = link.match(SOCKET_INODE_RE);
|
|
183
|
+
if (sock && inodes.has(sock[1])) {
|
|
184
|
+
try {
|
|
185
|
+
process.kill(Number(pid), "SIGKILL");
|
|
186
|
+
return true;
|
|
187
|
+
} catch {
|
|
188
|
+
return false;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
} catch { }
|
|
194
|
+
return false;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** What answers `GET /health` on a loopback port (see probePortHolder). */
|
|
198
|
+
export type PortHolderVerdict = "ours" | "foreign" | "silent" | "free";
|
|
199
|
+
|
|
200
|
+
/** Walk the undici cause chain for an ECONNREFUSED on the loopback probe. */
|
|
201
|
+
function isRefusedConnect(err: unknown): boolean {
|
|
202
|
+
let cur: unknown = err;
|
|
203
|
+
for (let depth = 0; depth < 4 && cur && typeof cur === "object"; depth++) {
|
|
204
|
+
if ("code" in cur && typeof cur.code === "string" && cur.code === "ECONNREFUSED") return true;
|
|
205
|
+
cur = "cause" in cur ? cur.cause : undefined;
|
|
206
|
+
}
|
|
207
|
+
return false;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Classify whatever holds `port` before any kill. `ours` means the
|
|
212
|
+
* unauthenticated identity probe answered with this daemon's service and the
|
|
213
|
+
* port it is bound to; `foreign` means something answered HTTP but is not a
|
|
214
|
+
* pi-freeflow daemon; `silent` means the TCP connection was accepted but no
|
|
215
|
+
* HTTP answer arrived in the budget (a wedged daemon or a non-HTTP listener);
|
|
216
|
+
* `free` means the connection was refused.
|
|
217
|
+
*/
|
|
218
|
+
export async function probePortHolder(port: number, timeoutMs = 600): Promise<PortHolderVerdict> {
|
|
219
|
+
try {
|
|
220
|
+
const res = await fetch(`http://${HOST}:${port}/health`, {
|
|
221
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
222
|
+
});
|
|
223
|
+
const body: unknown = await res.json().catch(() => null);
|
|
224
|
+
return isDaemonIdentityBody(port, body) ? "ours" : "foreign";
|
|
225
|
+
} catch (e) {
|
|
226
|
+
return isRefusedConnect(e) ? "free" : "silent";
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export async function killPortHolder(port: number): Promise<boolean> {
|
|
231
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) return false;
|
|
232
|
+
// Test-safety net: `node --test <file>` without test/setup.mjs runs on the
|
|
233
|
+
// real default port and would otherwise reap the developer's live daemon.
|
|
234
|
+
if (port === DEFAULT_PORT && process.env.NODE_TEST_CONTEXT) {
|
|
235
|
+
log("warn", `refusing to kill the default port :${port} from the test runner — run the suite via test/setup.mjs`);
|
|
236
|
+
return false;
|
|
237
|
+
}
|
|
238
|
+
// Never kill an identified process: probe first, and only a holder that
|
|
239
|
+
// claims (or cannot deny) being this daemon may be dislodged.
|
|
240
|
+
const holder = await probePortHolder(port);
|
|
241
|
+
if (holder === "free") return true;
|
|
242
|
+
if (holder === "foreign") {
|
|
243
|
+
log("warn", `refusing to kill :${port} — it answers /health as a foreign service, not a pi-freeflow daemon`);
|
|
244
|
+
return false;
|
|
245
|
+
}
|
|
246
|
+
const auth = tokenAuthHeaders();
|
|
247
|
+
try {
|
|
248
|
+
try {
|
|
249
|
+
await fetch(`http://${HOST}:${port}/_shutdown`, {
|
|
250
|
+
method: "POST",
|
|
251
|
+
headers: auth,
|
|
252
|
+
signal: AbortSignal.timeout(300),
|
|
253
|
+
});
|
|
254
|
+
for (let i = 0; i < 6; i++) {
|
|
255
|
+
await new Promise<void>((r) => setTimeout(r, 50));
|
|
256
|
+
if (!(await isProxyAlive(port))) return true;
|
|
257
|
+
}
|
|
258
|
+
} catch { }
|
|
259
|
+
if (process.platform === "win32") {
|
|
260
|
+
let killed = false;
|
|
261
|
+
const tryTaskkillPid = (rawPid: string | undefined): void => {
|
|
262
|
+
const pid = rawPid?.trim();
|
|
263
|
+
if (!pid || !DIGITS_ONLY_RE.test(pid)) return;
|
|
264
|
+
const num = Number(pid);
|
|
265
|
+
if (num <= 4 || num === process.pid) return; // never kill system/idle (0/4) or self
|
|
266
|
+
try {
|
|
267
|
+
execSync(`taskkill /F /T /PID ${pid}`, { timeout: 3000, stdio: "ignore", windowsHide: true });
|
|
268
|
+
killed = true;
|
|
269
|
+
} catch { }
|
|
270
|
+
};
|
|
271
|
+
try {
|
|
272
|
+
const out = execSync(`netstat -ano | findstr :${port}`, {
|
|
273
|
+
encoding: "utf8",
|
|
274
|
+
timeout: 3000,
|
|
275
|
+
windowsHide: true,
|
|
276
|
+
}) as string;
|
|
277
|
+
for (const line of out.split("\n")) {
|
|
278
|
+
const parts = line.trim().split(/\s+/);
|
|
279
|
+
if (!line.includes("LISTENING") && !(parts[0]?.toUpperCase() === "TCP" && (parts[2] === "0.0.0.0:0" || parts[2] === "[::]:0"))) continue;
|
|
280
|
+
if (!parts[1] || !parts[1].endsWith(`:${port}`)) continue;
|
|
281
|
+
tryTaskkillPid(parts[parts.length - 1]);
|
|
282
|
+
}
|
|
283
|
+
} catch { }
|
|
284
|
+
if (!killed) {
|
|
285
|
+
// Windows 11 24H2+ (build 26300+) can report Bound TCP listeners with
|
|
286
|
+
// PID 0 or omit them from netstat -ano; Get-NetTCPConnection still
|
|
287
|
+
// resolves the owning process id reliably.
|
|
288
|
+
try {
|
|
289
|
+
const psOut = execSync(
|
|
290
|
+
`powershell -NoProfile -Command "Get-NetTCPConnection -LocalPort ${port} -ErrorAction SilentlyContinue | Select-Object -ExpandProperty OwningProcess -Unique"`,
|
|
291
|
+
{ encoding: "utf8", timeout: 5000, windowsHide: true },
|
|
292
|
+
) as string;
|
|
293
|
+
for (const line of psOut.split(/\r?\n/)) {
|
|
294
|
+
tryTaskkillPid(line);
|
|
295
|
+
}
|
|
296
|
+
} catch { }
|
|
297
|
+
}
|
|
298
|
+
return killed;
|
|
299
|
+
}
|
|
300
|
+
try {
|
|
301
|
+
const out = execSync(`lsof -tiTCP:${port} -sTCP:LISTEN 2>/dev/null || fuser -n tcp ${port} 2>/dev/null`, {
|
|
302
|
+
encoding: "utf8",
|
|
303
|
+
timeout: 3000,
|
|
304
|
+
}) as string;
|
|
305
|
+
const pids = out.trim().split(/\s+/).filter((p) => DIGITS_ONLY_RE.test(p) && Number(p) !== process.pid);
|
|
306
|
+
// lsof matches both ends of a connection: our own recent probe socket
|
|
307
|
+
// (TIME_WAIT) can sort first, so skip self instead of taking [0].
|
|
308
|
+
if (pids.length === 0) return killViaProcNet(port);
|
|
309
|
+
execSync(`kill -9 ${pids[0]}`, { timeout: 3000, stdio: "ignore" });
|
|
310
|
+
return true;
|
|
311
|
+
} catch {
|
|
312
|
+
return killViaProcNet(port);
|
|
313
|
+
}
|
|
314
|
+
} catch {
|
|
315
|
+
return false;
|
|
316
|
+
}
|
|
317
|
+
}
|