pi-freeflow 1.0.6 → 1.0.8

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/README.md CHANGED
@@ -137,17 +137,6 @@ export default {
137
137
  status: response.status,
138
138
  headers: response.headers,
139
139
  });
140
- }
141
- };
142
- ```
143
-
144
- After deploying, register your Cloudflare Worker URL in OMP:
145
- ```text
146
- /freeflow use https://your-worker-name.your-subdomain.workers.dev
147
- ```
148
-
149
- ---
150
-
151
140
  ### 2. Vercel Relay
152
141
  Deploy using the built-in deploy command:
153
142
  ```text
@@ -159,13 +148,20 @@ Deploy using the built-in deploy command:
159
148
 
160
149
  ## 🛠️ Diagnostics & Troubleshooting
161
150
 
151
+ ### Custom Port Configuration
152
+ By default, `pi-freeflow` uses a **Single Shared Port** (`18080`). All concurrent sub-agents automatically share the same master proxy port without spawning redundant servers.
153
+
154
+ To change the base port:
155
+ ```env
156
+ FREEFLOW_PORT=19000
157
+ ```
158
+
162
159
  View live debug logs:
163
160
  ```bash
164
161
  cat ~/.pi/agent/pi-freeflow.log | tail -n 30
165
162
  # or inside OMP:
166
163
  /freeflow logs
167
164
  ```
168
-
169
165
  ---
170
166
 
171
167
  ## 📄 License
@@ -99,7 +99,11 @@ function loadRelayState(): RelayState {
99
99
  }
100
100
  function saveRelayState(s: RelayState): void {
101
101
  try {
102
- fs.writeFileSync(RELAY_STATE_FILE, JSON.stringify(s));
102
+ const dir = path.dirname(RELAY_STATE_FILE);
103
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
104
+ const tmpPath = `${RELAY_STATE_FILE}.${randomUUID()}.tmp`;
105
+ fs.writeFileSync(tmpPath, JSON.stringify(s, null, 2), "utf8");
106
+ fs.renameSync(tmpPath, RELAY_STATE_FILE);
103
107
  } catch (e) {
104
108
  log("warn", "could not persist relay state", { error: String(e) });
105
109
  }
@@ -663,6 +667,7 @@ const ALLOWED_METHODS = new Set(["GET", "POST", "OPTIONS", "HEAD"]);
663
667
  const STRIP_HEADERS = new Set([
664
668
  "authorization",
665
669
  "host",
670
+ "content-length",
666
671
  "x-forwarded-for",
667
672
  "x-forwarded-host",
668
673
  "x-forwarded-proto",
@@ -942,12 +947,16 @@ function normalizeRequestBody(
942
947
  }
943
948
  }
944
949
 
945
- // 3. Max tokens clamp for Vercel relay
946
- if (isRelay) {
947
- const mt = body.max_tokens ?? body.maxTokens;
948
- if (typeof mt === "number" && mt > RELAY_MAX_TOKENS) {
949
- body.max_tokens = RELAY_MAX_TOKENS;
950
- }
950
+ // 3. Max & Min tokens clamping (OpenCode Zen requires min 16; Vercel relay caps at 64k)
951
+ const mt = (body.max_tokens ?? body.maxTokens ?? body.max_output_tokens) as number | undefined;
952
+ if (typeof mt === "number") {
953
+ let clamped = mt;
954
+ if (clamped < 16) clamped = 16;
955
+ else if (isRelay && clamped > RELAY_MAX_TOKENS) clamped = RELAY_MAX_TOKENS;
956
+
957
+ if (body.max_tokens !== undefined) body.max_tokens = clamped;
958
+ if (body.maxTokens !== undefined) body.maxTokens = clamped;
959
+ if (body.max_output_tokens !== undefined) body.max_output_tokens = clamped;
951
960
  }
952
961
 
953
962
  return body;
@@ -1002,6 +1011,12 @@ function pipeUpstreamStream(
1002
1011
  req.on("close", () => {
1003
1012
  if (!nodeStream.destroyed) nodeStream.destroy();
1004
1013
  });
1014
+ res.on("close", () => {
1015
+ if (!nodeStream.destroyed) nodeStream.destroy();
1016
+ });
1017
+ res.on("error", () => {
1018
+ if (!nodeStream.destroyed) nodeStream.destroy();
1019
+ });
1005
1020
  }
1006
1021
 
1007
1022
  // ── Start local proxy ──────────────────────────────────────────────
@@ -1083,13 +1098,15 @@ function startProxy(
1083
1098
  if (isKilo && parsedBody) {
1084
1099
  // KiloCode gateway routing (free models are keyless)
1085
1100
  const isStream = parsedBody.stream === true;
1101
+ const kiloBodyObj = structuredClone(parsedBody);
1102
+ normalizeRequestBody(kiloBodyObj, true);
1086
1103
  const response = await relayFetch(KILO_CHAT_URL, {
1087
1104
  method: "POST",
1088
1105
  headers: {
1089
1106
  "Content-Type": "application/json",
1090
1107
  Authorization: "Bearer kilo-free",
1091
1108
  },
1092
- body: JSON.stringify(parsedBody),
1109
+ body: JSON.stringify(kiloBodyObj),
1093
1110
  signal: AbortSignal.timeout(300_000),
1094
1111
  });
1095
1112
  if (isStream && response.ok && response.body) {
@@ -1215,12 +1232,18 @@ function startProxy(
1215
1232
  res.end();
1216
1233
  }
1217
1234
  });
1218
- proxy.setTimeout(30_000, () => {
1235
+ proxy.setTimeout(300_000, () => {
1219
1236
  proxy.destroy(new Error("timeout"));
1220
1237
  });
1221
1238
  req.on("aborted", () => {
1222
1239
  if (!proxy.destroyed) proxy.destroy();
1223
1240
  });
1241
+ req.on("close", () => {
1242
+ if (!proxy.destroyed) proxy.destroy();
1243
+ });
1244
+ res.on("close", () => {
1245
+ if (!proxy.destroyed) proxy.destroy();
1246
+ });
1224
1247
  // ponytail: body already buffered in bodyChunks above for model routing;
1225
1248
  // req is drained so pipe() would send an empty body → upstream hang → 502.
1226
1249
  proxy.end(directBody);
@@ -1267,23 +1290,44 @@ function startProxy(
1267
1290
  });
1268
1291
  }
1269
1292
 
1270
- // ── Main extension ─────────────────────────────────────────────────
1271
- export default async function (pi: ExtensionAPI) {
1272
- log("info", "extension loading...");
1273
- let server: http.Server;
1274
- let actualPort: number;
1293
+ // Probe whether an existing freeflow proxy is already running on a port
1294
+ async function isProxyAlive(port: number): Promise<boolean> {
1275
1295
  try {
1276
- const r = await startProxy();
1277
- server = r.server;
1278
- actualPort = r.port;
1296
+ const res = await fetch(`http://${HOST}:${port}/v1/models`, {
1297
+ signal: AbortSignal.timeout(500),
1298
+ });
1299
+ const ct = res.headers.get("content-type") || "";
1300
+ return res.ok && ct.includes("application/json");
1279
1301
  } catch {
1280
- log(
1281
- "error",
1282
- "extension inactive — could not bind proxy port. resolve the port conflict and restart pi.",
1283
- );
1284
- return;
1302
+ return false;
1285
1303
  }
1304
+ }
1286
1305
 
1306
+ // ── Main extension ─────────────────────────────────────────────────
1307
+ export default async function (pi: ExtensionAPI) {
1308
+ log("info", "extension loading...");
1309
+ let server: http.Server | null = null;
1310
+ let actualPort = PORT;
1311
+
1312
+ // Single-Port Shared Pattern: If proxy is already running on PORT (e.g. parent session),
1313
+ // subagents reuse http://127.0.0.1:18080 directly without spawning redundant servers!
1314
+ const alreadyRunning = await isProxyAlive(PORT);
1315
+ if (alreadyRunning) {
1316
+ log("info", `reusing existing freeflow proxy on http://${HOST}:${PORT}`);
1317
+ actualPort = PORT;
1318
+ } else {
1319
+ try {
1320
+ const r = await startProxy();
1321
+ server = r.server;
1322
+ actualPort = r.port;
1323
+ } catch {
1324
+ log(
1325
+ "error",
1326
+ "extension inactive — could not bind proxy port. resolve the port conflict and restart pi.",
1327
+ );
1328
+ return;
1329
+ }
1330
+ }
1287
1331
  // Health check opencode models
1288
1332
  log("info", `checking ${KNOWN_MODELS.length} opencode model(s)...`);
1289
1333
  const opencodeChecks = await Promise.all(
@@ -1591,7 +1635,7 @@ export default async function (pi: ExtensionAPI) {
1591
1635
  pi.registerCommand("bansos", commandSpec);
1592
1636
 
1593
1637
  // Reload persisted state on session start/resume (env overrides still win).
1594
- pi.on("session_start", async (_event, ctx) => {
1638
+ pi.on?.("session_start", async (_event, ctx) => {
1595
1639
  relayState = resolveRelayState();
1596
1640
  statusUi = ctx.ui;
1597
1641
  ctx.ui?.setStatus?.("freeflow", undefined);
@@ -1600,7 +1644,7 @@ export default async function (pi: ExtensionAPI) {
1600
1644
 
1601
1645
  // Pi normally pauses after threshold compaction. Queue a follow-up while the
1602
1646
  // original run is still active so the core agent continues automatically.
1603
- pi.on("session_compact", (event, ctx) => {
1647
+ pi.on?.("session_compact", (event, ctx) => {
1604
1648
  if (
1605
1649
  event.reason !== "threshold" ||
1606
1650
  event.willRetry ||
@@ -1609,7 +1653,7 @@ export default async function (pi: ExtensionAPI) {
1609
1653
  ) {
1610
1654
  return;
1611
1655
  }
1612
- pi.sendUserMessage(
1656
+ pi.sendUserMessage?.(
1613
1657
  "Continue the current task from the compacted context. Do not wait for another user message; proceed with the next required step.",
1614
1658
  { deliverAs: "followUp" },
1615
1659
  );
package/package.json CHANGED
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "name": "pi-freeflow",
3
- "version": "1.0.6",
3
+ "type": "module",
4
+ "version": "1.0.8",
4
5
  "description": "Personal multi-cloud rolling fallback relay for OpenCode Zen and KiloCode models in OMP/Pi",
5
6
  "keywords": [
6
7
  "pi-package",