pi-freeflow 1.37.1 → 1.38.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 +10 -0
- package/package.json +1 -1
- package/src/client.ts +211 -27
- package/src/commands.ts +8 -4
- package/src/config.ts +54 -30
- package/src/proxy.ts +26 -0
- package/src/relay-state.ts +2 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.38.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- Follow pi's config-dir global: data files (relay state, logs, caches — including the staged daemon sources) now root at `PI_CODING_AGENT_DIR` when set, instead of always `~/.pi/agent`. CI and containers can relocate pi's config dir once and the extension writes alongside it. A leading `~` in either override expands to the home directory, and `PI_FREEFLOW_DATA_DIR` still overrides everything.
|
|
8
|
+
|
|
9
|
+
### Patch Changes
|
|
10
|
+
|
|
11
|
+
- Startup no longer stalls for several seconds when the extension is installed as a package and runs under Node.js: the detached proxy daemon is now launched from a staged copy of its sources (Node refuses to strip types for files under `node_modules`), a daemon that exits immediately is detected instead of being awaited for the full ready timeout, and a free base port is no longer mistaken for an unresponsive port holder.
|
|
12
|
+
|
|
3
13
|
## 1.37.1
|
|
4
14
|
|
|
5
15
|
### Patch Changes
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-freeflow",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "1.
|
|
4
|
+
"version": "1.38.0",
|
|
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",
|
package/src/client.ts
CHANGED
|
@@ -40,12 +40,14 @@ import {
|
|
|
40
40
|
RECOVERY_BACKOFF_BASE_MS,
|
|
41
41
|
RECOVERY_BACKOFF_CAP_MS,
|
|
42
42
|
refreshOpenCodeUserAgent,
|
|
43
|
+
resolveDaemonSrcCacheDir,
|
|
43
44
|
} from "./config.ts";
|
|
44
45
|
import { logInfo, logWarn } from "./logger.ts";
|
|
45
46
|
import {
|
|
46
47
|
getDaemonHealth,
|
|
47
48
|
getDaemonVersion,
|
|
48
49
|
isProxyAlive,
|
|
50
|
+
isPortBound,
|
|
49
51
|
killPortHolder,
|
|
50
52
|
reprobeBasePortAlive,
|
|
51
53
|
startProxy,
|
|
@@ -610,35 +612,42 @@ export function openDaemonLogFd(): number | null {
|
|
|
610
612
|
}
|
|
611
613
|
|
|
612
614
|
export type DarkHolderProbe = {
|
|
613
|
-
/** True when GET /v1/models answers on the port. */
|
|
615
|
+
/** True when GET /v1/models answers with a JSON catalog on the port. */
|
|
614
616
|
alive: boolean;
|
|
617
|
+
/** True when a TCP connection to the port succeeds (something holds it). */
|
|
618
|
+
bound: boolean;
|
|
615
619
|
/** Full /_health snapshot, or null when the holder answers nothing. */
|
|
616
620
|
health: HealthForRecovery;
|
|
617
621
|
};
|
|
618
622
|
|
|
619
623
|
/**
|
|
620
|
-
* Pure decision: reclaim a base port
|
|
621
|
-
* nor /_health. A live daemon —
|
|
622
|
-
* both from its event loop, so
|
|
623
|
-
* replacing it cannot interrupt a
|
|
624
|
+
* Pure decision: reclaim a base port that is actually held (TCP connect
|
|
625
|
+
* succeeds) yet answers neither /v1/models nor /_health. A live daemon —
|
|
626
|
+
* stale, busy, even mid-stream — always answers both from its event loop, so
|
|
627
|
+
* a dark holder cannot be serving traffic and replacing it cannot interrupt a
|
|
628
|
+
* real stream. A FREE port is not a dark holder: treating it as one cost a
|
|
629
|
+
* blind ~2s kill-and-poll on every cold start (issue #52).
|
|
624
630
|
*/
|
|
625
631
|
export function shouldReclaimDarkHolder(probe: DarkHolderProbe): boolean {
|
|
626
632
|
if (probe.alive) return false;
|
|
633
|
+
if (!probe.bound) return false;
|
|
627
634
|
if (probe.health !== null) return false;
|
|
628
635
|
return true;
|
|
629
636
|
}
|
|
630
637
|
|
|
631
638
|
/**
|
|
632
639
|
* Dislodge a dark holder from the base port so a fresh daemon can bind it.
|
|
633
|
-
* Returns true when a healthy proxy answers afterwards (attach to it);
|
|
634
|
-
*
|
|
635
|
-
* Honors the no-kill env opt-out; a live holder is never touched here —
|
|
636
|
-
*
|
|
640
|
+
* Returns true when a healthy proxy answers afterwards (attach to it); false
|
|
641
|
+
* when the caller should spawn fresh (the port is free — nothing to reclaim).
|
|
642
|
+
* Honors the no-kill env opt-out; a live holder is never touched here — the
|
|
643
|
+
* stale-replace path owns versioned daemons.
|
|
637
644
|
*/
|
|
638
645
|
async function reclaimDarkHolder(port: number): Promise<boolean> {
|
|
639
646
|
const alive = await isProxyAlive(port);
|
|
640
|
-
|
|
641
|
-
if (!
|
|
647
|
+
if (alive) return true;
|
|
648
|
+
if (!(await isPortBound(port))) return false; // port is free — spawn fresh
|
|
649
|
+
const health = await getDaemonHealth(port);
|
|
650
|
+
if (!shouldReclaimDarkHolder({ alive, bound: true, health })) return true;
|
|
642
651
|
if (NO_KILL_ENV && process.env[NO_KILL_ENV] === "1") {
|
|
643
652
|
logInfo(
|
|
644
653
|
`proxy on :${port} answers neither health nor models — leaving it alone (replacement disabled by env)`,
|
|
@@ -723,26 +732,185 @@ export async function ensureProxyReady(
|
|
|
723
732
|
return { ok: false, port, reason: proxyDownMessage(port) };
|
|
724
733
|
}
|
|
725
734
|
|
|
735
|
+
/** True when any path segment of `filePath` is `node_modules`. Node refuses to
|
|
736
|
+
* strip types for files under such a path (ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING),
|
|
737
|
+
* so a Node-spawned daemon must run from a staged copy elsewhere. */
|
|
738
|
+
export function isUnderNodeModules(filePath: string): boolean {
|
|
739
|
+
return filePath.split(/[\\/]/).includes("node_modules");
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
function readPackageJsonObject(dir: string): Record<string, unknown> | null {
|
|
743
|
+
try {
|
|
744
|
+
const raw: unknown = JSON.parse(fs.readFileSync(path.join(dir, "package.json"), "utf8"));
|
|
745
|
+
return raw && typeof raw === "object" ? (raw as Record<string, unknown>) : null;
|
|
746
|
+
} catch {
|
|
747
|
+
return null;
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
/** Resolve a dependency's package directory the way Node would: walk up the
|
|
752
|
+
* node_modules chain from the importing package's root. */
|
|
753
|
+
function resolvePackageDir(name: string, fromDir: string): string | null {
|
|
754
|
+
let dir = fromDir;
|
|
755
|
+
for (; ;) {
|
|
756
|
+
const pkgJson = path.join(dir, "node_modules", name, "package.json");
|
|
757
|
+
if (fs.existsSync(pkgJson)) return path.dirname(pkgJson);
|
|
758
|
+
const parent = path.dirname(dir);
|
|
759
|
+
if (parent === dir) return null;
|
|
760
|
+
dir = parent;
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
/** Link (or copy, when linking is unavailable) each runtime dependency beside
|
|
765
|
+
* the staged sources so bare imports such as `undici` still resolve. */
|
|
766
|
+
function stageRuntimeDependencies(pkgRoot: string, pkgJson: Record<string, unknown>, stagedDir: string): void {
|
|
767
|
+
const deps = pkgJson.dependencies;
|
|
768
|
+
if (!deps || typeof deps !== "object") return;
|
|
769
|
+
const nodeModulesDir = path.join(stagedDir, "node_modules");
|
|
770
|
+
for (const name of Object.keys(deps as Record<string, unknown>)) {
|
|
771
|
+
const target = resolvePackageDir(name, pkgRoot);
|
|
772
|
+
if (target === null) {
|
|
773
|
+
logWarn("daemon dependency not found for staging", { dependency: name });
|
|
774
|
+
continue;
|
|
775
|
+
}
|
|
776
|
+
try {
|
|
777
|
+
fs.mkdirSync(nodeModulesDir, { recursive: true });
|
|
778
|
+
// Junction on Windows (no elevated privileges needed); dir symlink elsewhere.
|
|
779
|
+
fs.symlinkSync(target, path.join(nodeModulesDir, name), process.platform === "win32" ? "junction" : "dir");
|
|
780
|
+
} catch {
|
|
781
|
+
try {
|
|
782
|
+
fs.cpSync(target, path.join(nodeModulesDir, name), { recursive: true, dereference: true });
|
|
783
|
+
} catch (e) {
|
|
784
|
+
logWarn("daemon dependency staging failed", { dependency: name, error: String(e) });
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
/** Best-effort prune of staged daemon sources from earlier versions (1 day TTL). */
|
|
791
|
+
function pruneStaleDaemonSrcDirs(root: string, currentVersion: string): void {
|
|
792
|
+
try {
|
|
793
|
+
const now = Date.now();
|
|
794
|
+
for (const entry of fs.readdirSync(root)) {
|
|
795
|
+
if (entry === currentVersion) continue;
|
|
796
|
+
const full = path.join(root, entry);
|
|
797
|
+
try {
|
|
798
|
+
if (now - fs.statSync(full).mtimeMs > 24 * 60 * 60 * 1000) {
|
|
799
|
+
fs.rmSync(full, { recursive: true, force: true });
|
|
800
|
+
}
|
|
801
|
+
} catch { }
|
|
802
|
+
}
|
|
803
|
+
} catch { }
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
/**
|
|
807
|
+
* Materialize the daemon's sources outside node_modules so a Node runtime can
|
|
808
|
+
* execute `src/daemon.ts` (Node refuses type stripping for any file under a
|
|
809
|
+
* node_modules path, and this package ships TypeScript sources). Copies
|
|
810
|
+
* `src/` + `package.json` into a per-version cache directory, stages the
|
|
811
|
+
* runtime dependencies beside them, and returns the staged daemon entry.
|
|
812
|
+
* Returns the input path unchanged when the script is already outside
|
|
813
|
+
* node_modules (dev checkouts) or the package root is unreadable.
|
|
814
|
+
*/
|
|
815
|
+
export function materializeDaemonSources(scriptPath: string, cacheDir?: string): string {
|
|
816
|
+
if (!isUnderNodeModules(scriptPath)) return scriptPath;
|
|
817
|
+
const pkgRoot = path.dirname(path.dirname(scriptPath));
|
|
818
|
+
const pkgJson = readPackageJsonObject(pkgRoot);
|
|
819
|
+
const version = pkgJson !== null && typeof pkgJson.version === "string" ? pkgJson.version : "";
|
|
820
|
+
if (pkgJson === null || !version) return scriptPath;
|
|
821
|
+
|
|
822
|
+
const root = cacheDir ?? resolveDaemonSrcCacheDir();
|
|
823
|
+
const finalDir = path.join(root, version);
|
|
824
|
+
const finalEntry = path.join(finalDir, "src", "daemon.ts");
|
|
825
|
+
if (fs.existsSync(finalEntry) && fs.existsSync(path.join(finalDir, "package.json"))) {
|
|
826
|
+
pruneStaleDaemonSrcDirs(root, version);
|
|
827
|
+
return finalEntry;
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
const tmpDir = `${finalDir}.tmp-${process.pid}-${randomUUID().slice(0, 8)}`;
|
|
831
|
+
try {
|
|
832
|
+
fs.mkdirSync(path.join(tmpDir, "src"), { recursive: true });
|
|
833
|
+
fs.cpSync(path.join(pkgRoot, "src"), path.join(tmpDir, "src"), { recursive: true });
|
|
834
|
+
fs.copyFileSync(path.join(pkgRoot, "package.json"), path.join(tmpDir, "package.json"));
|
|
835
|
+
stageRuntimeDependencies(pkgRoot, pkgJson, tmpDir);
|
|
836
|
+
try {
|
|
837
|
+
fs.renameSync(tmpDir, finalDir);
|
|
838
|
+
logInfo(`staged proxy daemon sources outside node_modules at ${finalDir}`);
|
|
839
|
+
} catch (e) {
|
|
840
|
+
// A concurrent starter won the rename (or the dir appeared meanwhile):
|
|
841
|
+
// its copy is byte-identical for the same version, so reuse it.
|
|
842
|
+
try {
|
|
843
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
844
|
+
} catch { }
|
|
845
|
+
if (!fs.existsSync(finalEntry)) {
|
|
846
|
+
logWarn("daemon source staging failed", { error: String(e), dir: finalDir });
|
|
847
|
+
return scriptPath;
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
} catch (e) {
|
|
851
|
+
try {
|
|
852
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
853
|
+
} catch { }
|
|
854
|
+
logWarn("daemon source staging failed — spawning from the installed path", { error: String(e), dir: tmpDir });
|
|
855
|
+
return scriptPath;
|
|
856
|
+
}
|
|
857
|
+
pruneStaleDaemonSrcDirs(root, version);
|
|
858
|
+
return finalEntry;
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
export interface DaemonChild {
|
|
862
|
+
pid?: number;
|
|
863
|
+
exitCode: number | null;
|
|
864
|
+
signalCode: string | null;
|
|
865
|
+
unref(): void;
|
|
866
|
+
on(event: "error", listener: (err: Error) => void): unknown;
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
export type DaemonSpawner = (
|
|
870
|
+
command: string,
|
|
871
|
+
args: string[],
|
|
872
|
+
options: Parameters<typeof spawn>[2],
|
|
873
|
+
) => DaemonChild;
|
|
874
|
+
|
|
875
|
+
let daemonSpawner: DaemonSpawner = spawn;
|
|
876
|
+
/** Children that failed to launch (spawn error) — the ready-wait treats them as exited. */
|
|
877
|
+
const failedDaemonSpawns = new WeakSet<DaemonChild>();
|
|
878
|
+
|
|
879
|
+
/** Test seam: replace the child spawner used for detached daemon starts. */
|
|
880
|
+
export function _setDaemonSpawnForTest(spawner: DaemonSpawner | null): void {
|
|
881
|
+
daemonSpawner = spawner ?? spawn;
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
/** True once a spawned daemon child has exited (exit code or signal). */
|
|
885
|
+
export function hasChildExited(child: Pick<DaemonChild, "exitCode" | "signalCode">): boolean {
|
|
886
|
+
return child.exitCode !== null || child.signalCode !== null;
|
|
887
|
+
}
|
|
888
|
+
|
|
726
889
|
let lastSpawnAt = 0;
|
|
727
890
|
const SPAWN_THROTTLE_MS = 2_000;
|
|
728
891
|
|
|
729
|
-
function spawnDaemonProcess():
|
|
892
|
+
function spawnDaemonProcess(): DaemonChild | null {
|
|
730
893
|
const now = Date.now();
|
|
731
894
|
if (now - lastSpawnAt < SPAWN_THROTTLE_MS) {
|
|
732
895
|
logWarn("daemon spawn throttled — recent spawn still pending");
|
|
733
|
-
return
|
|
896
|
+
return null;
|
|
734
897
|
}
|
|
735
898
|
const runtime = resolveDaemonRuntime();
|
|
736
899
|
if (!runtime) {
|
|
737
900
|
logWarn(
|
|
738
901
|
`cannot spawn detached proxy daemon: no node or bun runtime found (process.execPath "${process.execPath}" is not a JS runtime) — falling back to in-process proxy`,
|
|
739
902
|
);
|
|
740
|
-
return
|
|
903
|
+
return null;
|
|
741
904
|
}
|
|
905
|
+
// Node refuses to strip types for files under node_modules, so stage the
|
|
906
|
+
// sources elsewhere before spawning with a Node runtime (issue #52).
|
|
907
|
+
const scriptArg = runtime.args[runtime.args.length - 1] ?? "";
|
|
908
|
+
const stagedScript = runtime.runtimeType === "node" ? materializeDaemonSources(scriptArg) : scriptArg;
|
|
909
|
+
const args = stagedScript === scriptArg ? runtime.args : [...runtime.args.slice(0, -1), stagedScript];
|
|
742
910
|
lastSpawnAt = now;
|
|
743
911
|
const logFd = openDaemonLogFd();
|
|
744
912
|
try {
|
|
745
|
-
const child =
|
|
913
|
+
const child = daemonSpawner(runtime.execPath, args, {
|
|
746
914
|
detached: true,
|
|
747
915
|
stdio: logFd === null ? "ignore" : ["ignore", logFd, logFd],
|
|
748
916
|
windowsHide: true,
|
|
@@ -753,15 +921,16 @@ function spawnDaemonProcess(): boolean {
|
|
|
753
921
|
});
|
|
754
922
|
child.unref();
|
|
755
923
|
logInfo(
|
|
756
|
-
`spawned proxy daemon process (pid ${child.pid ?? "unknown"}) using ${runtime.runtimeType} (${runtime.execPath}) for :${PORT}`,
|
|
924
|
+
`spawned proxy daemon process (pid ${child.pid ?? "unknown"}) using ${runtime.runtimeType} (${runtime.execPath}) for :${PORT}${stagedScript === scriptArg ? "" : " [staged sources]"}`,
|
|
757
925
|
);
|
|
758
926
|
child.on("error", (err) => {
|
|
927
|
+
failedDaemonSpawns.add(child);
|
|
759
928
|
logWarn("daemon spawn failed", { error: String(err), execPath: runtime.execPath });
|
|
760
929
|
});
|
|
761
|
-
return
|
|
930
|
+
return child;
|
|
762
931
|
} catch (e) {
|
|
763
932
|
logWarn("daemon spawn failed", { error: String(e), execPath: runtime.execPath });
|
|
764
|
-
return
|
|
933
|
+
return null;
|
|
765
934
|
} finally {
|
|
766
935
|
if (logFd !== null) {
|
|
767
936
|
try {
|
|
@@ -771,10 +940,22 @@ function spawnDaemonProcess(): boolean {
|
|
|
771
940
|
}
|
|
772
941
|
}
|
|
773
942
|
|
|
774
|
-
async function waitForReady(
|
|
943
|
+
export async function waitForReady(
|
|
944
|
+
port: number,
|
|
945
|
+
timeoutMs: number,
|
|
946
|
+
child?: DaemonChild | null,
|
|
947
|
+
): Promise<boolean> {
|
|
775
948
|
const deadline = Date.now() + timeoutMs;
|
|
776
949
|
while (Date.now() < deadline) {
|
|
777
950
|
if (await isProxyAlive(port, 150)) return true;
|
|
951
|
+
if (child && (hasChildExited(child) || failedDaemonSpawns.has(child))) {
|
|
952
|
+
logWarn("daemon child exited before becoming ready", {
|
|
953
|
+
port,
|
|
954
|
+
exitCode: child.exitCode,
|
|
955
|
+
signalCode: child.signalCode,
|
|
956
|
+
});
|
|
957
|
+
return false;
|
|
958
|
+
}
|
|
778
959
|
await new Promise<void>((r) => setTimeout(r, 80));
|
|
779
960
|
}
|
|
780
961
|
return false;
|
|
@@ -812,16 +993,17 @@ export async function ensureDaemon(): Promise<number> {
|
|
|
812
993
|
|
|
813
994
|
|
|
814
995
|
if (isSpawnEnabled()) {
|
|
815
|
-
const
|
|
816
|
-
if (
|
|
817
|
-
const ready = await waitForReady(PORT, DAEMON_READY_TIMEOUT_MS);
|
|
996
|
+
const child = spawnDaemonProcess();
|
|
997
|
+
if (child) {
|
|
998
|
+
const ready = await waitForReady(PORT, DAEMON_READY_TIMEOUT_MS, child);
|
|
818
999
|
if (ready) {
|
|
819
1000
|
const ver = await getDaemonVersion(PORT);
|
|
820
1001
|
if (ver !== null && ver !== PKG_VERSION) {
|
|
821
1002
|
if (await killStaleDaemon(PORT, ver, "proxy daemon")) {
|
|
822
1003
|
lastSpawnAt = 0; // kill-confirmed retry bypasses the spawn throttle
|
|
823
|
-
|
|
824
|
-
|
|
1004
|
+
const retryChild = spawnDaemonProcess();
|
|
1005
|
+
if (retryChild) {
|
|
1006
|
+
const retryReady = await waitForReady(PORT, DAEMON_READY_TIMEOUT_MS, retryChild);
|
|
825
1007
|
if (retryReady) {
|
|
826
1008
|
await attachTo(PORT);
|
|
827
1009
|
return PORT;
|
|
@@ -836,13 +1018,14 @@ export async function ensureDaemon(): Promise<number> {
|
|
|
836
1018
|
return PORT;
|
|
837
1019
|
}
|
|
838
1020
|
} else {
|
|
839
|
-
// The spawn never answered:
|
|
840
|
-
//
|
|
1021
|
+
// The spawn never answered: the child exited before binding (e.g. Node
|
|
1022
|
+
// refusing type stripping under a node_modules path) or a dark holder
|
|
1023
|
+
// owns the base port (bind fails, probes fail).
|
|
841
1024
|
if (await reclaimDarkHolder(PORT)) {
|
|
842
1025
|
await attachTo(PORT);
|
|
843
1026
|
return PORT;
|
|
844
1027
|
}
|
|
845
|
-
logWarn("daemon spawn did not become ready —
|
|
1028
|
+
logWarn("daemon spawn did not become ready — child exited or the port is blocked");
|
|
846
1029
|
}
|
|
847
1030
|
}
|
|
848
1031
|
// Fall through to the in-process fallback below — never hand the caller
|
|
@@ -901,6 +1084,7 @@ export function _resetClientForTest(): void {
|
|
|
901
1084
|
attachedPort = 0;
|
|
902
1085
|
ensuring = false;
|
|
903
1086
|
lastSpawnAt = 0;
|
|
1087
|
+
daemonSpawner = spawn;
|
|
904
1088
|
_resetUaRefreshForTest();
|
|
905
1089
|
_resetRecoveryForTest();
|
|
906
1090
|
if (fallbackServer) {
|
package/src/commands.ts
CHANGED
|
@@ -10,7 +10,7 @@ import fs from "node:fs";
|
|
|
10
10
|
import os from "node:os";
|
|
11
11
|
import path from "node:path";
|
|
12
12
|
import { fileURLToPath } from "node:url";
|
|
13
|
-
import { getClientPort, isJsRuntimeExecutable, resolveDaemonRuntime } from "./client.ts";
|
|
13
|
+
import { getClientPort, isJsRuntimeExecutable, materializeDaemonSources, resolveDaemonRuntime } from "./client.ts";
|
|
14
14
|
import { refreshCatalog, setAliveCatalog } from "./catalog.ts";
|
|
15
15
|
import { DAEMON_SPAWN_ENV, DATA_DIR_ENV, DEBUG_STATE_FILE, HOST, LOG_FILE, PORT, RELAY_STATE_FILE } from "./config.ts";
|
|
16
16
|
import {
|
|
@@ -130,13 +130,17 @@ export function getStartupPlan(opts?: {
|
|
|
130
130
|
const platform = opts?.platform ?? process.platform;
|
|
131
131
|
const home = opts?.homeDir ?? os.homedir();
|
|
132
132
|
const script = daemonEntryForStartup(opts?.scriptPath);
|
|
133
|
+
// A Node runtime cannot strip types for files under node_modules, so the
|
|
134
|
+
// hook must point at staged sources when the package is installed (issue #52).
|
|
135
|
+
// Bun runs TypeScript anywhere — keep its command side-effect free.
|
|
136
|
+
const nodeEntry = (): string => materializeDaemonSources(script);
|
|
133
137
|
let quoted: string;
|
|
134
138
|
if (opts?.execPath) {
|
|
135
139
|
const type = isJsRuntimeExecutable(opts.execPath);
|
|
136
140
|
if (type === "bun") {
|
|
137
141
|
quoted = `"${opts.execPath}" "${script}"`;
|
|
138
142
|
} else {
|
|
139
|
-
quoted = `"${opts.execPath}" --experimental-strip-types "${
|
|
143
|
+
quoted = `"${opts.execPath}" --experimental-strip-types "${nodeEntry()}"`;
|
|
140
144
|
}
|
|
141
145
|
} else {
|
|
142
146
|
const runtime = resolveDaemonRuntime({ scriptPath: script });
|
|
@@ -144,10 +148,10 @@ export function getStartupPlan(opts?: {
|
|
|
144
148
|
if (runtime.runtimeType === "bun") {
|
|
145
149
|
quoted = `"${runtime.execPath}" "${script}"`;
|
|
146
150
|
} else {
|
|
147
|
-
quoted = `"${runtime.execPath}" --experimental-strip-types "${
|
|
151
|
+
quoted = `"${runtime.execPath}" --experimental-strip-types "${nodeEntry()}"`;
|
|
148
152
|
}
|
|
149
153
|
} else {
|
|
150
|
-
quoted = `"node" --experimental-strip-types "${
|
|
154
|
+
quoted = `"node" --experimental-strip-types "${nodeEntry()}"`;
|
|
151
155
|
}
|
|
152
156
|
}
|
|
153
157
|
if (platform === "win32") {
|
package/src/config.ts
CHANGED
|
@@ -295,19 +295,44 @@ export const STRIP_HEADERS = new Set([
|
|
|
295
295
|
]);
|
|
296
296
|
|
|
297
297
|
/** Env override that re-roots ALL pi-freeflow data files (tests/CI sandbox).
|
|
298
|
-
* Unset → ~/.pi/agent. */
|
|
298
|
+
* Unset → pi's agent dir (PI_CODING_AGENT_DIR, else ~/.pi/agent). */
|
|
299
299
|
export const DATA_DIR_ENV = "PI_FREEFLOW_DATA_DIR";
|
|
300
300
|
|
|
301
|
+
/** Env var pi itself uses to re-root its config dir: pi names it
|
|
302
|
+
* `<APP_NAME>_CODING_AGENT_DIR` (stock pi → PI_CODING_AGENT_DIR, else
|
|
303
|
+
* ~/.pi/agent). Freeflow data files follow it so CI/containers only need to
|
|
304
|
+
* relocate one root instead of granting write access to a hardcoded home
|
|
305
|
+
* path; when another host (e.g. the omp plugin loader) does not set it, the
|
|
306
|
+
* ~/.pi/agent default keeps the shared daemon/log location. */
|
|
307
|
+
export const AGENT_DIR_ENV = "PI_CODING_AGENT_DIR";
|
|
308
|
+
|
|
309
|
+
/** Expand a leading ~ the way pi expands its own agent-dir value. */
|
|
310
|
+
function expandHome(value: string): string {
|
|
311
|
+
if (value === "~") return homedir();
|
|
312
|
+
if (value.startsWith("~/") || value.startsWith("~\\")) return path.join(homedir(), value.slice(2));
|
|
313
|
+
return value;
|
|
314
|
+
}
|
|
315
|
+
|
|
301
316
|
function dataDirOverride(): string | null {
|
|
302
317
|
const d = process.env[DATA_DIR_ENV];
|
|
303
|
-
return typeof d === "string" && d.trim() !== "" ? d : null;
|
|
318
|
+
return typeof d === "string" && d.trim() !== "" ? expandHome(d) : null;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function defaultAgentDir(): string {
|
|
322
|
+
const d = process.env[AGENT_DIR_ENV];
|
|
323
|
+
if (typeof d === "string" && d.trim() !== "") return expandHome(d);
|
|
324
|
+
return path.join(homedir(), ".pi", "agent");
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function dataFilePath(name: string): string {
|
|
328
|
+
const override = dataDirOverride();
|
|
329
|
+
if (override) return path.join(override, name);
|
|
330
|
+
return path.join(defaultAgentDir(), name);
|
|
304
331
|
}
|
|
305
332
|
|
|
306
333
|
export function resolveRelayStatePath(): string {
|
|
307
334
|
try {
|
|
308
|
-
|
|
309
|
-
if (override) return path.join(override, "pi-freeflow-relay-state.json");
|
|
310
|
-
return path.join(homedir(), ".pi", "agent", "pi-freeflow-relay-state.json");
|
|
335
|
+
return dataFilePath("pi-freeflow-relay-state.json");
|
|
311
336
|
} catch {
|
|
312
337
|
return path.join(
|
|
313
338
|
path.dirname(fileURLToPath(import.meta.url)),
|
|
@@ -319,9 +344,7 @@ export function resolveRelayStatePath(): string {
|
|
|
319
344
|
|
|
320
345
|
export function resolveLogFilePath(): string {
|
|
321
346
|
try {
|
|
322
|
-
|
|
323
|
-
if (override) return path.join(override, "pi-freeflow.log");
|
|
324
|
-
return path.join(homedir(), ".pi", "agent", "pi-freeflow.log");
|
|
347
|
+
return dataFilePath("pi-freeflow.log");
|
|
325
348
|
} catch {
|
|
326
349
|
return path.join(
|
|
327
350
|
path.dirname(fileURLToPath(import.meta.url)),
|
|
@@ -331,16 +354,27 @@ export function resolveLogFilePath(): string {
|
|
|
331
354
|
}
|
|
332
355
|
}
|
|
333
356
|
|
|
334
|
-
|
|
357
|
+
/**
|
|
358
|
+
* Root directory for staged daemon sources — copies taken outside
|
|
359
|
+
* node_modules because Node's type stripping refuses files under a
|
|
360
|
+
* node_modules path (ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING, issue #52).
|
|
361
|
+
* Version subdirectories live inside it.
|
|
362
|
+
*/
|
|
363
|
+
export function resolveDaemonSrcCacheDir(): string {
|
|
335
364
|
try {
|
|
336
|
-
|
|
337
|
-
|
|
365
|
+
return dataFilePath("pi-freeflow-daemon-src");
|
|
366
|
+
} catch {
|
|
338
367
|
return path.join(
|
|
339
|
-
|
|
340
|
-
"
|
|
341
|
-
"
|
|
342
|
-
"pi-freeflow-catalog-cache.json",
|
|
368
|
+
path.dirname(fileURLToPath(import.meta.url)),
|
|
369
|
+
"..",
|
|
370
|
+
".pi-freeflow-daemon-src",
|
|
343
371
|
);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
export function resolveCatalogCachePath(): string {
|
|
376
|
+
try {
|
|
377
|
+
return dataFilePath("pi-freeflow-catalog-cache.json");
|
|
344
378
|
} catch {
|
|
345
379
|
return path.join(
|
|
346
380
|
path.dirname(fileURLToPath(import.meta.url)),
|
|
@@ -352,9 +386,7 @@ export function resolveCatalogCachePath(): string {
|
|
|
352
386
|
|
|
353
387
|
export function resolveDebugStatePath(): string {
|
|
354
388
|
try {
|
|
355
|
-
|
|
356
|
-
if (override) return path.join(override, "pi-freeflow-debug.json");
|
|
357
|
-
return path.join(homedir(), ".pi", "agent", "pi-freeflow-debug.json");
|
|
389
|
+
return dataFilePath("pi-freeflow-debug.json");
|
|
358
390
|
} catch {
|
|
359
391
|
return path.join(
|
|
360
392
|
path.dirname(fileURLToPath(import.meta.url)),
|
|
@@ -366,9 +398,7 @@ export function resolveDebugStatePath(): string {
|
|
|
366
398
|
|
|
367
399
|
export function resolveUpdateCachePath(): string {
|
|
368
400
|
try {
|
|
369
|
-
|
|
370
|
-
if (override) return path.join(override, "pi-freeflow-update.json");
|
|
371
|
-
return path.join(homedir(), ".pi", "agent", "pi-freeflow-update.json");
|
|
401
|
+
return dataFilePath("pi-freeflow-update.json");
|
|
372
402
|
} catch {
|
|
373
403
|
return path.join(
|
|
374
404
|
path.dirname(fileURLToPath(import.meta.url)),
|
|
@@ -380,9 +410,7 @@ export function resolveUpdateCachePath(): string {
|
|
|
380
410
|
|
|
381
411
|
export function resolveOnboardedFlagPath(): string {
|
|
382
412
|
try {
|
|
383
|
-
|
|
384
|
-
if (override) return path.join(override, "pi-freeflow-onboarded");
|
|
385
|
-
return path.join(homedir(), ".pi", "agent", "pi-freeflow-onboarded");
|
|
413
|
+
return dataFilePath("pi-freeflow-onboarded");
|
|
386
414
|
} catch {
|
|
387
415
|
return path.join(
|
|
388
416
|
path.dirname(fileURLToPath(import.meta.url)),
|
|
@@ -394,9 +422,7 @@ export function resolveOnboardedFlagPath(): string {
|
|
|
394
422
|
|
|
395
423
|
export function resolveUpstreamHealthPath(): string {
|
|
396
424
|
try {
|
|
397
|
-
|
|
398
|
-
if (override) return path.join(override, "pi-freeflow-upstream-health.json");
|
|
399
|
-
return path.join(homedir(), ".pi", "agent", "pi-freeflow-upstream-health.json");
|
|
425
|
+
return dataFilePath("pi-freeflow-upstream-health.json");
|
|
400
426
|
} catch {
|
|
401
427
|
return path.join(
|
|
402
428
|
path.dirname(fileURLToPath(import.meta.url)),
|
|
@@ -408,9 +434,7 @@ export function resolveUpstreamHealthPath(): string {
|
|
|
408
434
|
|
|
409
435
|
export function resolveSessionPinPath(): string {
|
|
410
436
|
try {
|
|
411
|
-
|
|
412
|
-
if (override) return path.join(override, "pi-freeflow-session-pins.json");
|
|
413
|
-
return path.join(homedir(), ".pi", "agent", "pi-freeflow-session-pins.json");
|
|
437
|
+
return dataFilePath("pi-freeflow-session-pins.json");
|
|
414
438
|
} catch {
|
|
415
439
|
return path.join(
|
|
416
440
|
path.dirname(fileURLToPath(import.meta.url)),
|
package/src/proxy.ts
CHANGED
|
@@ -10,6 +10,7 @@ import fs from "node:fs";
|
|
|
10
10
|
import { execSync } from "node:child_process";
|
|
11
11
|
import * as http from "node:http";
|
|
12
12
|
import * as https from "node:https";
|
|
13
|
+
import net from "node:net";
|
|
13
14
|
import { handleHealthRequest, isLoopbackIP } from "./health.ts";
|
|
14
15
|
import type { DaemonHealthSnapshot } from "./health.ts";
|
|
15
16
|
import { registerClient, renewClient, touchActivity, unregisterClient } from "./lease.ts";
|
|
@@ -828,6 +829,31 @@ export async function isProxyAlive(port: number, timeoutMs = 600): Promise<boole
|
|
|
828
829
|
}
|
|
829
830
|
}
|
|
830
831
|
|
|
832
|
+
/**
|
|
833
|
+
* True when a TCP connection to the loopback port succeeds — something holds
|
|
834
|
+
* the port (our daemon, a stale daemon, or an unrelated process). A free port
|
|
835
|
+
* refuses the connection immediately; the dark-holder reclaim path uses this
|
|
836
|
+
* to tell "nothing bound" apart from "bound but unresponsive" (issue #52: the
|
|
837
|
+
* blind kill-and-poll cost ~2s on every cold start).
|
|
838
|
+
*/
|
|
839
|
+
export async function isPortBound(port: number, timeoutMs = 250): Promise<boolean> {
|
|
840
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) return false;
|
|
841
|
+
return await new Promise<boolean>((resolve) => {
|
|
842
|
+
const socket = net.connect({ host: HOST, port });
|
|
843
|
+
let settled = false;
|
|
844
|
+
const settle = (value: boolean): void => {
|
|
845
|
+
if (settled) return;
|
|
846
|
+
settled = true;
|
|
847
|
+
socket.destroy();
|
|
848
|
+
resolve(value);
|
|
849
|
+
};
|
|
850
|
+
socket.setTimeout(timeoutMs, () => settle(false));
|
|
851
|
+
socket.once("connect", () => settle(true));
|
|
852
|
+
socket.once("error", () => settle(false));
|
|
853
|
+
});
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
|
|
831
857
|
let activeRequests = 0;
|
|
832
858
|
|
|
833
859
|
/** Current in-flight proxied requests (guard for stale-daemon replacement). */
|
package/src/relay-state.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Persistent relay state manager and failover ordering for pi-freeflow
|
|
3
3
|
*
|
|
4
|
-
* Handles atomic state writes to
|
|
4
|
+
* Handles atomic state writes to pi's agent dir (PI_CODING_AGENT_DIR,
|
|
5
|
+
* default ~/.pi/agent) under pi-freeflow-relay-state.json.
|
|
5
6
|
*/
|
|
6
7
|
|
|
7
8
|
import { randomUUID } from "node:crypto";
|