pi-freeflow 1.37.0 → 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 CHANGED
@@ -1,5 +1,23 @@
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
+
13
+ ## 1.37.1
14
+
15
+ ### Patch Changes
16
+
17
+ - **DeepSeek V4.1 Flash (`deepseek-v4.1-flash-free`)**: Added to the OpenCode Zen lane (catalog 34 → 35 models). Configured with full **1,000,000 context window**, **384,000 max output tokens**, vision support (`input: ["text", "image"]`), and reasoning ladder (`minimal..max`).
18
+ - **Sources of Truth & Highest-Spec Twin Policy**: Documented model reconciliation against [models.dev](https://models.dev/), [OpenRouter](https://openrouter.ai/), and OMP's built-in catalog, ensuring models are configured to the highest capability across twin models rather than artificially truncated limits.
19
+ - **Automated OpenCode Model Health Bot**: Added dedicated GitHub Actions workflow and probe engine to continuously detect affected or degraded OpenCode free-tier models.
20
+
3
21
  ## 1.37.0
4
22
 
5
23
  ### Minor Changes
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # pi-freeflow
2
2
 
3
- > 34 free models with up to 1M context. No API keys to manage. Add your own relays to spread requests across more IPs.
3
+ > 35 free models with up to 1M context. No API keys to manage. Add your own relays to spread requests across more IPs.
4
4
 
5
5
  Thin by design: a model list, a relay proxy, and a log. The host (`pi-ai`) handles thinking, normalization, and provider behavior.
6
6
 
@@ -16,7 +16,7 @@ Thin by design: a model list, a relay proxy, and a log. The host (`pi-ai`) handl
16
16
 
17
17
  | Feature | What it does | Cost |
18
18
  | :--- | :--- | :--- |
19
- | **34 free models** | 10 from OpenCode Zen, 14 from KiloCode Gateway, 2 from Cline, 8 from OVHcloud AI Endpoints, context windows up to 1M. Full list below. | **$0** |
19
+ | **35 free models** | 11 from OpenCode Zen, 14 from KiloCode Gateway, 2 from Cline, 8 from OVHcloud AI Endpoints, context windows up to 1M. Full list below. | **$0** |
20
20
  | **Relay pool** | Route requests through your own Cloudflare Workers, Deno Deploy, and Vercel Edge relays. Traffic spreads across the pool, or stays on one sticky relay. A relay that rate-limits, times out, or drops the connection cools down while healthy ones take its traffic. | **$0** beyond your platforms' free tiers |
21
21
  | **Automatic fallback** | When every relay is cooling down, requests go direct to upstream instead of failing. | **$0** |
22
22
  | **Short model names** | Most models have a slash-free, colon-free alias, plus an optional `:effort` suffix for thinking depth. You type `freeflow/<name>`; the two Cline models have no alias and must be typed in full. | **$0** |
@@ -197,13 +197,14 @@ The same command set works identically in OMP and Pi:
197
197
 
198
198
  ---
199
199
 
200
- ### 34 models, one command
200
+ ### 35 models, one command
201
201
 
202
202
  ```bash
203
203
  /model → freeflow → pick
204
204
  ```
205
205
 
206
- #### OpenCode Zen (10 models), Responses and Chat API
206
+ Every model specification is reconciled against [models.dev](https://models.dev/), [OpenRouter](https://openrouter.ai/), and OMP's built-in catalog, configured with the highest verified context, output ceiling, and reasoning capabilities across twin models.
207
+ #### OpenCode Zen (11 models), Responses and Chat API
207
208
 
208
209
  Good defaults for long coding sessions and agentic work.
209
210
 
@@ -219,6 +220,7 @@ Good defaults for long coding sessions and agentic work.
219
220
  | `space-bunny-free` | Stealth preview (lab undisclosed) | **1M** (1.048.576) | **512K** (524.288) | `low … max` | ✅ |
220
221
  | `longcat-2.5-preview-free` | Meituan LongCat | **1M** (1.000.000) | **131K** (131.072) | `minimal … max` | ✅ |
221
222
  | `step-5-preview-free` | StepFun | **1M** (1.000.000) | **64K** (65.536) | `low … high` | ✅ |
223
+ | `deepseek-v4.1-flash-free` | DeepSeek | **1M** (1.000.000) | **384K** (384.000) | `minimal … max` | ✅ |
222
224
 
223
225
  #### KiloCode Gateway (14 models), OpenRouter compatible
224
226
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pi-freeflow",
3
3
  "type": "module",
4
- "version": "1.37.0",
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 whose holder answers neither /v1/models
621
- * nor /_health. A live daemon — stale, busy, even mid-stream — always answers
622
- * both from its event loop, so a dark holder cannot be serving traffic and
623
- * replacing it cannot interrupt a real stream.
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
- * false when the caller should spawn fresh (port presumably free).
635
- * Honors the no-kill env opt-out; a live holder is never touched here —
636
- * the stale-replace path owns versioned daemons.
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
- const health = alive ? null : await getDaemonHealth(port);
641
- if (!shouldReclaimDarkHolder({ alive, health })) return true;
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(): boolean {
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 false;
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 false;
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 = spawn(runtime.execPath, runtime.args, {
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 true;
930
+ return child;
762
931
  } catch (e) {
763
932
  logWarn("daemon spawn failed", { error: String(e), execPath: runtime.execPath });
764
- return false;
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(port: number, timeoutMs: number): Promise<boolean> {
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 spawned = spawnDaemonProcess();
816
- if (spawned) {
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
- if (spawnDaemonProcess()) {
824
- const retryReady = await waitForReady(PORT, DAEMON_READY_TIMEOUT_MS);
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: it crashed instantly or a dark holder owns
840
- // the base port (bind fails, probes fail).
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 — is the port blocked?");
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 "${script}"`;
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 "${script}"`;
151
+ quoted = `"${runtime.execPath}" --experimental-strip-types "${nodeEntry()}"`;
148
152
  }
149
153
  } else {
150
- quoted = `"node" --experimental-strip-types "${script}"`;
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
- const override = dataDirOverride();
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
- const override = dataDirOverride();
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
- export function resolveCatalogCachePath(): string {
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
- const override = dataDirOverride();
337
- if (override) return path.join(override, "pi-freeflow-catalog-cache.json");
365
+ return dataFilePath("pi-freeflow-daemon-src");
366
+ } catch {
338
367
  return path.join(
339
- homedir(),
340
- ".pi",
341
- "agent",
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
- const override = dataDirOverride();
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
- const override = dataDirOverride();
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
- const override = dataDirOverride();
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
- const override = dataDirOverride();
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
- const override = dataDirOverride();
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/index.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * pi-freeflow — Modular, high-resiliency LLM extension for Pi & Oh My Pi (OMP)
3
3
  *
4
- * Provides access to 37 free models (11 OpenCode Zen + 15 KiloCode Gateway + 2 Cline + 9 OVH) with:
4
+ * Provides access to 35 free models (11 OpenCode Zen + 14 KiloCode Gateway + 2 Cline + 8 OVH) with:
5
5
  * - Single-port daemon reuse on 28180 across concurrent subagents
6
6
  * - Multi-cloud rolling egress relays (Vercel Edge, Cloudflare, Deno)
7
7
  * - 0ms instant startup with verified static catalog and background live health checks
package/src/models.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * Static model definitions and upstream routing catalogs for pi-freeflow
3
3
  *
4
- * Defines the 34 verified free models (live-verified 2026-10-15):
5
- * - 10 OpenCode Zen models (2 Responses API + 8 Chat Completions)
4
+ * Defines the 35 verified free models (live-verified 2026-10-15):
5
+ * - 11 OpenCode Zen models (2 Responses API + 9 Chat Completions)
6
6
  * - 14 KiloCode Keyless Gateway models (OpenRouter format)
7
7
  * - 2 Cline direct-only models (per-user pool)
8
8
  * - 8 OVHcloud AI Endpoints models (keyless, direct-only)
@@ -200,6 +200,40 @@ export const OPENCODE_MODELS: ModelDef[] = [
200
200
  max: null,
201
201
  },
202
202
  },
203
+ {
204
+ // Added 2026-10-15: new free model on the OpenCode Zen lane. Live-verified by
205
+ // probe, NOT copied from the published catalog — the entry for this exact
206
+ // free id claims 200,000 context, which the gateway contradicts (see below).
207
+ id: "deepseek-v4.1-flash-free",
208
+ name: "DeepSeek V4.1 Flash [OpenCode]",
209
+ reasoning: true,
210
+ // 1,048,576 measured, not assumed: a marker planted mid-prompt is still
211
+ // recalled through prompt_tokens=1,030,800, and the gateway turns an
212
+ // oversized request away with an empty-bodied 400 somewhere above ~1.05M.
213
+ // The published 200k belongs to the paid deepseek-v4-flash-free, a different
214
+ // model with a confusingly similar name. Acceptance is not capability; here
215
+ // the two agree, which is why this is the declared number.
216
+ contextWindow: 1_000_000,
217
+ // 384,000 output tokens per https://models.dev/models/deepseek/deepseek-v4.1-flash/
218
+ // and live gateway boundary probe (gateway accepts up to 393,216 = 384KiB, rejects 393,217+ with 400).
219
+ maxTokens: 384_000,
220
+ // Chat Completions only — a Responses-shaped request is refused with
221
+ // 400 ModelProtocolUnsupported. `api` left unset, which means chat.
222
+ input: ["text", "image"],
223
+ // Vision verified live (a 1x1 PNG reads back as "Pink"). Video is not
224
+ // advertised for this model upstream, so it is not offered here.
225
+ // Every effort level is accepted and visibly changes reasoning length, so
226
+ // the whole ladder stays selectable; `off` is always null.
227
+ thinkingLevelMap: {
228
+ off: null,
229
+ minimal: "minimal",
230
+ low: "low",
231
+ medium: "medium",
232
+ high: "high",
233
+ xhigh: "xhigh",
234
+ max: "max",
235
+ },
236
+ },
203
237
  ];
204
238
 
205
239
  /**
@@ -589,6 +623,7 @@ export const MODEL_ALIASES: Record<string, string> = {
589
623
  "longcat-2.5-preview": "longcat-2.5-preview-free",
590
624
  "longcat": "longcat-2.5-preview-free",
591
625
  "step-5-preview": "step-5-preview-free",
626
+ "deepseek-v4.1-flash": "deepseek-v4.1-flash-free",
592
627
  // provider-prefixed short aliases (slash-normalized)
593
628
  "laguna-s-2.1:free": "poolside/laguna-s-2.1:free",
594
629
  "laguna-xs-2.1:free": "poolside/laguna-xs-2.1:free",
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). */
@@ -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 ~/.pi/agent/pi-freeflow-relay-state.json.
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";