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.
Files changed (46) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/README.md +11 -13
  3. package/package.json +1 -1
  4. package/src/catalog.ts +38 -14
  5. package/src/client-recovery.ts +238 -0
  6. package/src/client-staging.ts +316 -0
  7. package/src/client.ts +117 -472
  8. package/src/cline-accounts.ts +26 -1223
  9. package/src/cline-device-auth.ts +5 -3
  10. package/src/cline-pool.ts +691 -0
  11. package/src/cline-roll.ts +642 -0
  12. package/src/commands-cline.ts +283 -0
  13. package/src/commands-relay.ts +808 -0
  14. package/src/commands-startup.ts +264 -0
  15. package/src/commands.ts +119 -1202
  16. package/src/config-paths.ts +254 -0
  17. package/src/config.ts +6 -687
  18. package/src/daemon.ts +16 -1
  19. package/src/deploy.ts +3 -1
  20. package/src/fingerprint-env.ts +334 -0
  21. package/src/fingerprint-tools.ts +348 -0
  22. package/src/index.ts +10 -6
  23. package/src/lane-translation.ts +263 -0
  24. package/src/models.ts +70 -112
  25. package/src/opencode-fingerprint.ts +29 -1310
  26. package/src/opencode-identity.ts +464 -0
  27. package/src/proxy-lanes.ts +1125 -0
  28. package/src/proxy-ports.ts +317 -0
  29. package/src/proxy.ts +74 -1385
  30. package/src/relay-codec.ts +365 -0
  31. package/src/relay-state.ts +10 -367
  32. package/src/relay.ts +78 -31
  33. package/src/sse-aggregator.ts +659 -0
  34. package/src/stream-cloak.ts +882 -0
  35. package/src/stream-pipe.ts +22 -871
  36. package/src/system-ca-fetch.ts +15 -4
  37. package/src/token.ts +7 -2
  38. package/src/tool-args.ts +10 -3
  39. package/src/tool-translation.ts +1 -236
  40. package/src/toolcall-audit.ts +20 -7
  41. package/src/update-checker.ts +17 -1
  42. package/src/update-core.ts +5 -2
  43. package/src/updater.ts +8 -3
  44. package/src/upstream-health.ts +22 -2
  45. package/src/zen-sidecar.ts +1 -0
  46. package/src/zen-transport.ts +48 -18
package/src/config.ts CHANGED
@@ -2,12 +2,9 @@
2
2
  * Configuration and path resolution for pi-freeflow
3
3
  */
4
4
 
5
- import { spawnSync } from "node:child_process";
6
- import { createHash, randomBytes } from "node:crypto";
7
- import { homedir } from "node:os";
5
+ import { readFileSync } from "node:fs";
8
6
  import path from "node:path";
9
7
  import { fileURLToPath } from "node:url";
10
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
11
8
 
12
9
  // Package version — stale-daemon detection in the shared-port reuse path.
13
10
  let PKG_VERSION = "0.0.0";
@@ -112,444 +109,6 @@ export function resolvePort(): number {
112
109
 
113
110
  export const PORT = resolvePort();
114
111
 
115
- // ── OpenCode client headers ─────────────────────────────────────────
116
- // Live UA layer, grounded in reference/opencode:
117
- // - packages/opencode/src/session/llm/request.ts sends `User-Agent: USER_AGENT`
118
- // on every opencode-provider request.
119
- // - packages/opencode/src/installation/index.ts builds it as
120
- // `opencode/${InstallationVersion}` (plugin probes) or
121
- // `opencode/${InstallationChannel}/${InstallationVersion}/${client}` (CLI).
122
- // - packages/core/src/installation/version.ts falls back to "local" when the
123
- // build-time OPENCODE_VERSION global is absent.
124
- // Free-tier floor: opencodeHeaders() must never send below 1.17.0.
125
- // OPENCODE_VERSION_FLOOR pins the minimum; OPENCODE_VERSION_FALLBACK is the
126
- // last-known-good npm version used when the live lookup is stale/offline.
127
- export const OPENCODE_VERSION_FLOOR = "1.17.0";
128
- export const OPENCODE_VERSION_FALLBACK = "1.19.4";
129
- /** Live npm lookup TTL: 6h — within one window the cached UA is reused. */
130
- export const OPENCODE_VERSION_TTL_MS = 6 * 60 * 60 * 1_000;
131
- /** Env override: exact `opencode/<version>` UA (or bare version) for tests/pins. */
132
- export const OPENCODE_USER_AGENT_ENV = "PI_FREEFLOW_OPENCODE_USER_AGENT";
133
- /** Owned UA probe cache file (sibling of the update-check cache). */
134
- export const OPENCODE_VERSION_CACHE_ENV = "PI_FREEFLOW_OPENCODE_VERSION_CACHE";
135
- /** Opt-in 4-token UA: "1" emits opencode/latest/<live>/cli (2.x allowed); "channel/client" customizes. Unset → default 3-token shape. */
136
- export const OPENCODE_UA_V2_ENV = "PI_FREEFLOW_OPENCODE_UA_V2";
137
- /** Opt-in project variant: "cwd" hashes cwd via sha1; unset/any other → "global". Default byte-identical. */
138
- export const OPENCODE_PROJECT_ENV = "PI_FREEFLOW_OPENCODE_PROJECT";
139
- /** Working directory for the project header when PROJECT=cwd; ignored on every other variant. */
140
- export const OPENCODE_CWD_ENV = "PI_FREEFLOW_OPENCODE_CWD";
141
- /** Opt-in extra header allowlist (comma-separated, e.g. b3,traceparent,accept); default allowlist unchanged. */
142
- export const OPENCODE_EXTRA_HEADERS_ENV = "PI_FREEFLOW_OPENCODE_EXTRA_HEADERS";
143
-
144
- export const OPENCODE_CLIENT = "cli";
145
-
146
- function compareVersionParts(a: string, b: string): number {
147
- const pa = a.split(".").map((p) => Number.parseInt(p, 10));
148
- const pb = b.split(".").map((p) => Number.parseInt(p, 10));
149
- for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
150
- const na = pa[i] ?? 0;
151
- const nb = pb[i] ?? 0;
152
- if (na !== nb) return na < nb ? -1 : 1;
153
- }
154
- return 0;
155
- }
156
-
157
- /** True when `version` is a strict `major.minor.patch` at or above the floor. */
158
- export function isSupportedOpenCodeVersion(version: unknown): boolean {
159
- if (typeof version !== "string") return false;
160
- if (!/^\d+\.\d+\.\d+$/.test(version)) return false;
161
- return compareVersionParts(version, OPENCODE_VERSION_FLOOR) >= 0;
162
- }
163
-
164
- /** Normalize an override/registry version into an `opencode/<v>` UA, else null. */
165
- export function toOpenCodeUserAgent(version: unknown): string | null {
166
- if (typeof version !== "string") return null;
167
- const v = version.trim();
168
- if (!v) return null;
169
- const bare = v.startsWith("opencode/") ? v.slice("opencode/".length) : v;
170
- if (!isSupportedOpenCodeVersion(bare)) return null;
171
- return `opencode/${bare}`;
172
- }
173
-
174
- /** Memo TTL for the installed-binary probe: at most one spawn per minute. */
175
- export const OPENCODE_BINARY_VERSION_TTL_MS = 60_000;
176
- /** Where an observed live OpenCode version came from. */
177
- export type OpenCodeVersionSource = "registry" | "binary";
178
-
179
- /**
180
- * Parse `opencode --version` output into a bare semver, else null.
181
- * Accepts the shapes the CLIs print (`2.2.0`, `v2.2.0`, `opencode-ai 1.19.4`)
182
- * and floors through isSupportedOpenCodeVersion, so anything below 1.17.0
183
- * (or unparseable) never drives the wire UA.
184
- */
185
- export function parseOpenCodeBinaryVersion(output: unknown): string | null {
186
- if (typeof output !== "string") return null;
187
- const match = output.match(/(\d+\.\d+\.\d+)/);
188
- if (!match) return null;
189
- const version: string | undefined = match[1];
190
- if (version === undefined || !isSupportedOpenCodeVersion(version)) return null;
191
- return version;
192
- }
193
-
194
- /** Minimal spawnSync result surface the binary probe reads. */
195
- export interface OpenCodeSpawnResult {
196
- status: number | null;
197
- stdout?: unknown;
198
- stderr?: unknown;
199
- error?: unknown;
200
- }
201
- /** spawnSync-compatible injector for the binary probe (tests stub this). */
202
- export type OpenCodeSpawnSync = (
203
- command: string,
204
- args: readonly string[],
205
- options: { encoding: "utf8"; timeout: number; windowsHide: boolean; shell?: boolean },
206
- ) => OpenCodeSpawnResult;
207
-
208
- function defaultOpenCodeSpawn(
209
- command: string,
210
- args: readonly string[],
211
- options: { encoding: "utf8"; timeout: number; windowsHide: boolean; shell?: boolean },
212
- ): OpenCodeSpawnResult {
213
- // Single-string form under a Windows shell: resolves *.cmd shims the same
214
- // way isCommandAvailable does; direct exec elsewhere.
215
- if (process.platform === "win32") {
216
- return spawnSync(`${command} ${args.join(" ")}`, {
217
- encoding: options.encoding,
218
- timeout: options.timeout,
219
- windowsHide: options.windowsHide,
220
- shell: true,
221
- }) as unknown as OpenCodeSpawnResult;
222
- }
223
- return spawnSync(command, [...args], options) as unknown as OpenCodeSpawnResult;
224
- }
225
-
226
- let binaryMemo: { key: string; version: string | null; probedAt: number } | null = null;
227
-
228
- /**
229
- * Probe the locally installed `opencode` binary (`--version`, 10s timeout).
230
- * Offline-safe by construction: no network, try/catch, null when the binary
231
- * is missing, fails, times out, or prints an unsupported version. Results
232
- * (including misses) are memoized ~60s so the request path never spawns
233
- * more than once a minute. Pass `spawnImpl` to stub the spawn in tests —
234
- * never rely on a real PATH binary there (CI has none).
235
- */
236
- export function probeInstalledOpenCodeVersion(opts: {
237
- execPath?: string;
238
- timeoutMs?: number;
239
- spawnImpl?: OpenCodeSpawnSync;
240
- } = {}): string | null {
241
- const execPath = opts.execPath ?? "opencode";
242
- const timeoutMs = opts.timeoutMs ?? 10_000;
243
- const now = Date.now();
244
- if (binaryMemo && binaryMemo.key === execPath && now - binaryMemo.probedAt < OPENCODE_BINARY_VERSION_TTL_MS) {
245
- return binaryMemo.version;
246
- }
247
- let version: string | null = null;
248
- try {
249
- const spawn = opts.spawnImpl ?? defaultOpenCodeSpawn;
250
- const res = spawn(execPath, ["--version"], { encoding: "utf8", timeout: timeoutMs, windowsHide: true });
251
- if (res && res.error === undefined && res.status === 0) {
252
- const text = [res.stdout, res.stderr].filter((part): part is string => typeof part === "string").join("\n");
253
- version = parseOpenCodeBinaryVersion(text);
254
- }
255
- } catch { version = null; }
256
- binaryMemo = { key: execPath, version, probedAt: now };
257
- return version;
258
- }
259
-
260
- function openCodeVersionCachePath(): string {
261
- const override = process.env[OPENCODE_VERSION_CACHE_ENV];
262
- if (typeof override === "string" && override.trim() !== "") return override;
263
- try {
264
- return path.join(path.dirname(resolveUpdateCachePath()), "pi-freeflow-opencode-version.json");
265
- } catch {
266
- return path.join(path.dirname(fileURLToPath(import.meta.url)), "..", ".opencode-version.json");
267
- }
268
- }
269
-
270
- interface OpenCodeVersionCache { version: string; checkedAt: number; }
271
-
272
- function readOpenCodeVersionCache(): OpenCodeVersionCache | null {
273
- try {
274
- const raw = readFileSync(openCodeVersionCachePath(), "utf8");
275
- const parsed: unknown = JSON.parse(raw);
276
- if (!parsed || typeof parsed !== "object") return null;
277
- const { version, checkedAt } = parsed as Record<string, unknown>;
278
- if (!isSupportedOpenCodeVersion(version)) return null;
279
- if (typeof checkedAt !== "number" || !Number.isFinite(checkedAt)) return null;
280
- return { version: version as string, checkedAt };
281
- } catch { return null; }
282
- }
283
-
284
- function writeOpenCodeVersionCache(version: string): void {
285
- try {
286
- const file = openCodeVersionCachePath();
287
- const dir = path.dirname(file);
288
- if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
289
- writeFileSync(file, JSON.stringify({ version, checkedAt: Date.now() }), "utf8");
290
- } catch { }
291
- }
292
-
293
- let liveOpenCodeVersion: string | null = null;
294
- let liveOpenCodeVersionSource: OpenCodeVersionSource | null = null;
295
- /** Memoized disk read: path -> { version, fileCheckedAt } so opencodeHeaders() stays sync and cheap. */
296
- let diskMemo: { path: string; version: string | null; readAt: number } | null = null;
297
- const DISK_MEMO_TTL_MS = 60_000;
298
-
299
- function memoizedDiskVersion(): string | null {
300
- const file = openCodeVersionCachePath();
301
- const now = Date.now();
302
- if (diskMemo && diskMemo.path === file && now - diskMemo.readAt < DISK_MEMO_TTL_MS) return diskMemo.version;
303
- const cached = readOpenCodeVersionCache();
304
- const version = cached && now - cached.checkedAt < OPENCODE_VERSION_TTL_MS ? cached.version : null;
305
- diskMemo = { path: file, version, readAt: now };
306
- return version;
307
- }
308
-
309
- /** Test-only: reset the in-process live version (cache file untouched). */
310
- export function _resetLiveOpenCodeVersionForTest(): void {
311
- liveOpenCodeVersion = null;
312
- liveOpenCodeVersionSource = null;
313
- diskMemo = null;
314
- binaryMemo = null;
315
- }
316
-
317
- /**
318
- * Current OpenCode version for the wire UA — the single precedence chain:
319
- * env override > installed binary (1.x line) > in-process live version >
320
- * fresh disk cache > pinned fallback. Never throws, never touches the
321
- * network (disk re-read at most once a minute, binary probe at most once a
322
- * minute). An installed 2.x binary never drives the compat-lane UA — it is
323
- * skipped here and surfaces via getLiveOpenCodeVersion for logs only.
324
- */
325
- function currentOpenCodeVersion(): string {
326
- const override = toOpenCodeUserAgent(process.env[OPENCODE_USER_AGENT_ENV]);
327
- if (override) return override.slice("opencode/".length);
328
- const installed = probeInstalledOpenCodeVersion();
329
- if (installed && installed.split(".")[0] === "1") return installed;
330
- if (liveOpenCodeVersion && isSupportedOpenCodeVersion(liveOpenCodeVersion)) return liveOpenCodeVersion;
331
- const disk = memoizedDiskVersion();
332
- if (disk) return disk;
333
- return OPENCODE_VERSION_FALLBACK;
334
- }
335
-
336
- /**
337
- * Synchronous UA getter — the single User-Agent implementation every lane
338
- * sends (offline-safe by construction). Default the verbatim CLI full shape.
339
- * With PI_FREEFLOW_OPENCODE_UA_V2=1, the 4-token latest/cli shape with the
340
- * live version (2.x allowed); "channel/client" customizes both tokens.
341
- */
342
- export function getOpenCodeUserAgent(): string {
343
- const v2raw = process.env[OPENCODE_UA_V2_ENV];
344
- const v2 = typeof v2raw === "string" ? v2raw.trim() : "";
345
- if (v2.length > 0) {
346
- if (v2 === "1") return buildOpenCodeUserAgent(getLiveOpenCodeVersion(), { channel: "latest", client: OPENCODE_CLIENT });
347
- const parts = v2.split("/").map((p) => p.trim()).filter((p) => p.length > 0);
348
- if (parts.length === 2) return buildOpenCodeUserAgent(getLiveOpenCodeVersion(), { channel: parts[0], client: parts[1] });
349
- }
350
- return buildOpenCodeUserAgent(currentOpenCodeVersion());
351
- }
352
-
353
- /**
354
- * Refresh the live UA from `npm view opencode-ai version` (registry metadata,
355
- * same source the installer layer queries for the latest channel build).
356
- * Offline-safe: any failure keeps the pinned fallback and returns it.
357
- * A fetched version below the floor never replaces the fallback.
358
- */
359
- export async function refreshOpenCodeUserAgent(fetchImpl: typeof fetch = fetch): Promise<string> {
360
- const override = toOpenCodeUserAgent(process.env[OPENCODE_USER_AGENT_ENV]);
361
- if (override) return getOpenCodeUserAgent();
362
- try {
363
- const res = await fetchImpl("https://registry.npmjs.org/opencode-ai/latest", {
364
- signal: AbortSignal.timeout(10_000),
365
- });
366
- if (!res.ok) return getOpenCodeUserAgent();
367
- const body: unknown = await res.json();
368
- const version = (body as Record<string, unknown>)?.version;
369
- if (!isSupportedOpenCodeVersion(version)) return getOpenCodeUserAgent();
370
- liveOpenCodeVersion = version as string;
371
- liveOpenCodeVersionSource = "registry";
372
- writeOpenCodeVersionCache(version as string);
373
- diskMemo = null;
374
- return getOpenCodeUserAgent();
375
- } catch { return getOpenCodeUserAgent(); }
376
- }
377
- /**
378
- * Live version already fetched in-process, else the installed binary, else
379
- * the pinned fallback. The installed 2.x line surfaces here (and in logs)
380
- * while the compat-lane UA keeps the 1.x line — major-2 never auto-switches
381
- * the wire shape. No network; the binary probe is memoized ~60s.
382
- */
383
- export function getLiveOpenCodeVersion(): string {
384
- if (liveOpenCodeVersion) return liveOpenCodeVersion;
385
- return probeInstalledOpenCodeVersion() ?? OPENCODE_VERSION_FALLBACK;
386
- }
387
- /**
388
- * Where the live version came from: `registry` when refresh populated it,
389
- * `binary` when it reflects the installed binary, null on the pure fallback.
390
- */
391
- export function getOpenCodeVersionSource(): OpenCodeVersionSource | null {
392
- if (liveOpenCodeVersion) return liveOpenCodeVersionSource;
393
- if (probeInstalledOpenCodeVersion() !== null) return "binary";
394
- return null;
395
- }
396
-
397
- /**
398
- * Build the OpenCode User-Agent string: default the verbatim CLI full shape
399
- * (`opencode/<version> ai-sdk/provider-utils/4.0.23 runtime/bun/1.4.4`).
400
- * With both channel+client, the 4-token CLI shape
401
- * (`opencode/<channel>/<version>/<client> ai-sdk/... runtime/...`).
402
- * The legacy bare shape is gone with the A/B profiles.
403
- */
404
- export function buildOpenCodeUserAgent(version: string, opts?: { channel?: string; client?: string }): string {
405
- if (typeof opts?.channel === "string" && opts.channel.length > 0 && typeof opts?.client === "string" && opts.client.length > 0) {
406
- return `opencode/${opts.channel}/${version}/${opts.client} ai-sdk/provider-utils/4.0.23 runtime/bun/1.4.4`;
407
- }
408
- return `opencode/${version} ai-sdk/provider-utils/4.0.23 runtime/bun/1.4.4`;
409
- }
410
-
411
- const STABLE_SESSION_RE = /^ses_[0-9a-f]{12}[0-9A-Za-z]{13,14}$/;
412
- const STABLE_SESSION_CAP = 1000;
413
- const stableSessionCache = new Map<string, string>();
414
- const TIME48_MASK = (1n << 48n) - 1n;
415
-
416
- /**
417
- * Stable per-conversation session id. A valid `ses_*` key passes through
418
- * verbatim; "" allocates a fresh random id; anything else maps deterministically
419
- * via sha256("pi-freeflow\\0" + key): 48-bit time field from the digest with the
420
- * descending counter pinned at 1, plus 14 base62 chars from later digest bytes.
421
- * Results are memoized in an LRU (cap 1000, hit moves to back); `now` is accepted
422
- * for signature compatibility and ignored so the same key always maps the same.
423
- */
424
- export function stableOpenCodeSessionId(key: string, now = Date.now()): string {
425
- void now;
426
- if (STABLE_SESSION_RE.test(key)) return key;
427
- if (key === "") return createOpenCodeSessionId();
428
- const hit = stableSessionCache.get(key);
429
- if (hit !== undefined) {
430
- stableSessionCache.delete(key);
431
- stableSessionCache.set(key, hit);
432
- return hit;
433
- }
434
- const digest = createHash("sha256").update(`pi-freeflow\0${key}`).digest();
435
- const time48 = (BigInt(digest[0]!) << 40n) | (BigInt(digest[1]!) << 32n) | (BigInt(digest[2]!) << 24n) | (BigInt(digest[3]!) << 16n) | (BigInt(digest[4]!) << 8n) | BigInt(digest[5]!);
436
- const time = ((~(time48 * 0x1000n + 1n)) & TIME48_MASK).toString(16).padStart(12, "0");
437
- let rand = "";
438
- for (let i = 0; i < 14; i++) rand += ID_CHARS[digest[(6 + i) % digest.length]! % 62];
439
- const id = `ses_${time}${rand}`;
440
- stableSessionCache.set(key, id);
441
- if (stableSessionCache.size > STABLE_SESSION_CAP) {
442
- const oldest = stableSessionCache.keys().next();
443
- if (!oldest.done) stableSessionCache.delete(oldest.value);
444
- }
445
- return id;
446
- }
447
-
448
- /** Test-only: clear the stable session memo. */
449
- export function _resetStableSessionCacheForTest(): void { stableSessionCache.clear(); }
450
-
451
- /** Test-only: current stable session memo size (proves the LRU cap). */
452
- export function _stableSessionCacheSizeForTest(): number { return stableSessionCache.size; }
453
-
454
- /** Test-only: whether a key is currently memoized (proves LRU eviction). */
455
- export function _stableSessionHasKeyForTest(key: string): boolean { return stableSessionCache.has(key); }
456
- /** The literal `global` project the CLI sends outside a repo. */
457
- export const OPENCODE_PROJECT_GLOBAL = "global";
458
- /** sha1 hex digest of a working directory (what the CLI sends as the project). */
459
- export function openCodeProjectForCwd(cwd: string): string {
460
- return createHash("sha1").update(cwd).digest("hex");
461
- }
462
-
463
- const ID_CHARS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
464
- let idTimestamp = 0;
465
- let idCounter = 0;
466
-
467
- /**
468
- * Generate a 26-character OpenCode-compatible identifier.
469
- * Matches packages/schema/src/identifier.ts in anomalyco/opencode:
470
- * 12 hex characters encoding inverted (descending) or direct (ascending)
471
- * timestamp (BigInt(timestamp) * 0x1000n + counter, ~ when descending),
472
- * followed by 14 random base62 characters.
473
- */
474
- export function createOpenCodeId(descending: boolean, timestamp = Date.now()): string {
475
- if (timestamp !== idTimestamp) {
476
- idTimestamp = timestamp;
477
- idCounter = 0;
478
- }
479
- idCounter++;
480
-
481
- const current = BigInt(timestamp) * 0x1000n + BigInt(idCounter);
482
- const value = descending ? ~current : current;
483
- const time = Array.from({ length: 6 }, (_, index) =>
484
- Number((value >> BigInt(40 - 8 * index)) & 0xffn)
485
- .toString(16)
486
- .padStart(2, "0"),
487
- ).join("");
488
- const bytes = randomBytes(14);
489
- return time + Array.from(bytes, (byte) => ID_CHARS[byte % 62]).join("");
490
- }
491
-
492
- // Upstream v1.19.4 refactored `SessionID.create` to compose the prefix itself
493
- // (`schema.make("ses_" + descending())` in packages/schema/src/session-id.ts).
494
- // The identifier generator is unchanged, so this call is still byte-for-byte
495
- // equivalent: `ses_` + 12 hex timestamp chars + 14 base62 random chars.
496
- export function createOpenCodeSessionId(): string {
497
- return `ses_${createOpenCodeId(true)}`;
498
- }
499
-
500
- export function createOpenCodeRequestId(): string {
501
- return `msg_${createOpenCodeId(false)}`;
502
- }
503
-
504
- /**
505
- * Live project header: legacy cwd hash preserved, env only overrides. Unset
506
- * PI_FREEFLOW_OPENCODE_PROJECT keeps today's behavior (cwd → sha1, else
507
- * `global`), so the wire default (no cwd threaded) stays `global` and
508
- * existing callers/tests are unchanged. "global" forces global even with a
509
- * cwd; "cwd" hashes the param or PI_FREEFLOW_OPENCODE_CWD (else global).
510
- */
511
- export function resolveOpenCodeProjectLive(cwd?: string): string {
512
- const variant = process.env[OPENCODE_PROJECT_ENV];
513
- if (variant === "cwd") {
514
- const effective = typeof cwd === "string" && cwd.length > 0 ? cwd : process.env[OPENCODE_CWD_ENV];
515
- if (typeof effective === "string" && effective.length > 0) return openCodeProjectForCwd(effective);
516
- return OPENCODE_PROJECT_GLOBAL;
517
- }
518
- if (variant === "global") return OPENCODE_PROJECT_GLOBAL;
519
- if (typeof cwd === "string" && cwd.length > 0) return openCodeProjectForCwd(cwd);
520
- return OPENCODE_PROJECT_GLOBAL;
521
- }
522
-
523
- /**
524
- * Live-truth OpenCode headers (v1 1.19.4 MITM, SG): full UA, Bearer public,
525
- * client cli, per-conversation ses_* session, fresh msg_* request id per
526
- * prepare, and the cwd-derived project. Affinity headers are NEVER sent on
527
- * the opencode lane (upstream treats them as a non-OpenCode signal); they
528
- * are gated to non-opencode lanes only (see sanitizeHeaders callers).
529
- *
530
- * @param sessionKey conversation-scoped key reused within a run
531
- * (stableOpenCodeSessionId maps it deterministically; a valid ses_* passes
532
- * through verbatim). Omitted/empty allocates a fresh id for this call.
533
- * @param cwd working directory for the project header; omitted → `global`
534
- * unless PI_FREEFLOW_OPENCODE_PROJECT=cwd supplies one through
535
- * PI_FREEFLOW_OPENCODE_CWD.
536
- */
537
- export function opencodeHeaders(sessionKey?: string, cwd?: string): Record<string, string> {
538
- const sessionId =
539
- typeof sessionKey === "string" && sessionKey.length > 0
540
- ? stableOpenCodeSessionId(sessionKey)
541
- : createOpenCodeSessionId();
542
- return {
543
- Authorization: "Bearer public",
544
- "User-Agent": getOpenCodeUserAgent(),
545
- "x-opencode-client": OPENCODE_CLIENT,
546
- "x-opencode-project": resolveOpenCodeProjectLive(cwd),
547
- "x-opencode-session": sessionId,
548
- "x-opencode-session-id": sessionId,
549
- "x-opencode-request": createOpenCodeRequestId(),
550
- };
551
- }
552
-
553
112
  // ── Relay and Deployment constants ──────────────────────────────────
554
113
  export const VERCEL_API = "https://api.vercel.com";
555
114
 
@@ -579,254 +138,10 @@ export const STRIP_HEADERS = new Set([
579
138
  "proxy-authorization",
580
139
  ]);
581
140
 
582
- /** Env override that re-roots ALL pi-freeflow data files (tests/CI sandbox).
583
- * Unset → pi's agent dir (PI_CODING_AGENT_DIR, else ~/.pi/agent). */
584
- export const DATA_DIR_ENV = "PI_FREEFLOW_DATA_DIR";
585
-
586
- /** Env var pi itself uses to re-root its config dir: pi names it
587
- * `<APP_NAME>_CODING_AGENT_DIR` (stock pi → PI_CODING_AGENT_DIR, else
588
- * ~/.pi/agent). Freeflow data files follow it so CI/containers only need to
589
- * relocate one root instead of granting write access to a hardcoded home
590
- * path; when another host (e.g. the omp plugin loader) does not set it, the
591
- * ~/.pi/agent default keeps the shared daemon/log location. */
592
- export const AGENT_DIR_ENV = "PI_CODING_AGENT_DIR";
593
-
594
- /** Expand a leading ~ the way pi expands its own agent-dir value. */
595
- function expandHome(value: string): string {
596
- if (value === "~") return homedir();
597
- if (value.startsWith("~/") || value.startsWith("~\\")) return path.join(homedir(), value.slice(2));
598
- return value;
599
- }
600
-
601
- function dataDirOverride(): string | null {
602
- const d = process.env[DATA_DIR_ENV];
603
- return typeof d === "string" && d.trim() !== "" ? expandHome(d) : null;
604
- }
605
-
606
- function defaultAgentDir(): string {
607
- const d = process.env[AGENT_DIR_ENV];
608
- if (typeof d === "string" && d.trim() !== "") return expandHome(d);
609
- return path.join(homedir(), ".pi", "agent");
610
- }
611
-
612
- function dataFilePath(name: string): string {
613
- const override = dataDirOverride();
614
- if (override) return path.join(override, name);
615
- return path.join(defaultAgentDir(), name);
616
- }
617
-
618
- /**
619
- * Per-installation proxy token file (src/token.ts reads/creates it at 0600).
620
- * Sits with the other data files so a sandboxed data dir relocates it too.
621
- */
622
- export function resolveTokenPath(): string {
623
- try {
624
- return dataFilePath("token");
625
- } catch {
626
- return path.join(
627
- path.dirname(fileURLToPath(import.meta.url)),
628
- "..",
629
- ".pi-freeflow-token",
630
- );
631
- }
632
- }
633
-
634
- export function resolveRelayStatePath(): string {
635
- try {
636
- return dataFilePath("pi-freeflow-relay-state.json");
637
- } catch {
638
- return path.join(
639
- path.dirname(fileURLToPath(import.meta.url)),
640
- "..",
641
- ".relay-state.json",
642
- );
643
- }
644
- }
645
-
646
- export function resolveLogFilePath(): string {
647
- try {
648
- return dataFilePath("pi-freeflow.log");
649
- } catch {
650
- return path.join(
651
- path.dirname(fileURLToPath(import.meta.url)),
652
- "..",
653
- "pi-freeflow.log",
654
- );
655
- }
656
- }
657
-
658
- /**
659
- * Root directory for staged daemon sources — copies taken outside
660
- * node_modules because Node's type stripping refuses files under a
661
- * node_modules path (ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING, issue #52).
662
- * Version subdirectories live inside it.
663
- */
664
- export function resolveDaemonSrcCacheDir(): string {
665
- try {
666
- return dataFilePath("pi-freeflow-daemon-src");
667
- } catch {
668
- return path.join(
669
- path.dirname(fileURLToPath(import.meta.url)),
670
- "..",
671
- ".pi-freeflow-daemon-src",
672
- );
673
- }
674
- }
675
-
676
- export function resolveCatalogCachePath(): string {
677
- try {
678
- return dataFilePath("pi-freeflow-catalog-cache.json");
679
- } catch {
680
- return path.join(
681
- path.dirname(fileURLToPath(import.meta.url)),
682
- "..",
683
- ".catalog-cache.json",
684
- );
685
- }
686
- }
687
-
688
- export function resolveDebugStatePath(): string {
689
- try {
690
- return dataFilePath("pi-freeflow-debug.json");
691
- } catch {
692
- return path.join(
693
- path.dirname(fileURLToPath(import.meta.url)),
694
- "..",
695
- ".debug-state.json",
696
- );
697
- }
698
- }
699
-
700
- export function resolveUpdateCachePath(): string {
701
- try {
702
- return dataFilePath("pi-freeflow-update.json");
703
- } catch {
704
- return path.join(
705
- path.dirname(fileURLToPath(import.meta.url)),
706
- "..",
707
- ".update-cache.json",
708
- );
709
- }
710
- }
711
-
712
- export function resolveOnboardedFlagPath(): string {
713
- try {
714
- return dataFilePath("pi-freeflow-onboarded");
715
- } catch {
716
- return path.join(
717
- path.dirname(fileURLToPath(import.meta.url)),
718
- "..",
719
- ".onboarded",
720
- );
721
- }
722
- }
723
-
724
- export function resolveUpstreamHealthPath(): string {
725
- try {
726
- return dataFilePath("pi-freeflow-upstream-health.json");
727
- } catch {
728
- return path.join(
729
- path.dirname(fileURLToPath(import.meta.url)),
730
- "..",
731
- ".upstream-health.json",
732
- );
733
- }
734
- }
735
-
736
- /** JSONL audit of tool-call turns that failed or needed repair (src/toolcall-audit.ts). */
737
- export function resolveToolcallAuditPath(): string {
738
- try {
739
- return dataFilePath("toolcall-audit.jsonl");
740
- } catch {
741
- return path.join(
742
- path.dirname(fileURLToPath(import.meta.url)),
743
- "..",
744
- ".toolcall-audit.jsonl",
745
- );
746
- }
747
- }
748
-
749
- export function resolveSessionPinPath(): string {
750
- try {
751
- return dataFilePath("pi-freeflow-session-pins.json");
752
- } catch {
753
- return path.join(
754
- path.dirname(fileURLToPath(import.meta.url)),
755
- "..",
756
- ".session-pins.json",
757
- );
758
- }
759
- }
760
-
761
- export const RELAY_STATE_FILE = resolveRelayStatePath();
762
- export const LOG_FILE = resolveLogFilePath();
763
- export const CATALOG_CACHE_FILE = resolveCatalogCachePath();
764
- export const DEBUG_STATE_FILE = resolveDebugStatePath();
765
- export const UPDATE_CACHE_FILE = resolveUpdateCachePath();
766
- export const ONBOARDED_FLAG_FILE = resolveOnboardedFlagPath();
767
- export const UPDATE_CHECK_TTL_MS = 86_400_000;
768
- // ── Security & Relay Validation ─────────────────────────────────────
769
- /** Opt-out for tests/dev: when set to "1", relay URLs on http:// and private hosts are allowed. */
770
- export const ALLOW_UNSAFE_RELAY_ENV = "PI_FREEFLOW_ALLOW_UNSAFE_RELAY";
771
- /** Opt-out for the stale-daemon replace: when "1", a version-mismatched daemon is never killed. */
772
- export const NO_KILL_ENV = ALLOW_UNSAFE_RELAY_ENV.replace("_ALLOW_UNSAFE_RELAY", "_NO_KILL");
773
- /** When "0", the extension never spawns a detached proxy daemon (tests/CI). */
774
- export const DAEMON_SPAWN_ENV = DATA_DIR_ENV.replace("_DATA_DIR", "_DAEMON_SPAWN");
775
- /** Explicit runtime executable path for daemon.ts (overrides process.execPath and PATH search). */
776
- export const DAEMON_RUNTIME_ENV = DATA_DIR_ENV.replace("_DATA_DIR", "_DAEMON_RUNTIME");
777
- /** Lease TTL for attached clients (ms); expired leases are dropped by the daemon GC. */
778
- export const DAEMON_TTL_MS_ENV = DATA_DIR_ENV.replace("_DATA_DIR", "_DAEMON_TTL_MS");
779
- /** Client heartbeat interval (ms) — must stay well under the lease TTL. */
780
- export const DAEMON_HEARTBEAT_MS_ENV = DATA_DIR_ENV.replace("_DATA_DIR", "_DAEMON_HEARTBEAT_MS");
781
- /** Daemon GC sweep interval (ms). */
782
- export const DAEMON_GC_MS_ENV = DATA_DIR_ENV.replace("_DATA_DIR", "_DAEMON_GC_MS");
783
- /** Zero-lease persistence window before a lease-less daemon exits (ms). */
784
- export const DAEMON_GRACE_MS_ENV = DATA_DIR_ENV.replace("_DATA_DIR", "_DAEMON_GRACE_MS");
785
- /** Max time a client waits for a freshly spawned daemon to answer /_health (ms). */
786
- export const DAEMON_READY_TIMEOUT_MS_ENV = DATA_DIR_ENV.replace("_DATA_DIR", "_DAEMON_READY_TIMEOUT_MS");
787
- /** Timeout for a single loopback control call (attach/heartbeat/detach) (ms). */
788
- export const DAEMON_CONTROL_TIMEOUT_MS_ENV = DATA_DIR_ENV.replace("_DATA_DIR", "_DAEMON_CONTROL_TIMEOUT_MS");
789
- /** Client watchdog tick: how often /_health is polled for version + failed-SSE rate (ms). */
790
- export const DAEMON_WATCHDOG_MS_ENV = DATA_DIR_ENV.replace("_DATA_DIR", "_DAEMON_WATCHDOG_MS");
791
-
792
- function envMs(name: string, fallback: number): number {
793
- const raw = process.env[name];
794
- if (raw) {
795
- const parsed = Number(raw);
796
- if (Number.isFinite(parsed) && parsed > 0) return parsed;
797
- }
798
- return fallback;
799
- }
800
-
801
- const IS_WINDOWS_HOST = process.platform === "win32";
802
- export const DAEMON_SPAWN_ENABLED = process.env[DAEMON_SPAWN_ENV] !== "0";
803
- export const DAEMON_TTL_MS = envMs(DAEMON_TTL_MS_ENV, 30_000);
804
- export const DAEMON_HEARTBEAT_MS = envMs(DAEMON_HEARTBEAT_MS_ENV, IS_WINDOWS_HOST ? 8_000 : 10_000);
805
- export const DAEMON_GC_MS = envMs(DAEMON_GC_MS_ENV, 5_000);
806
- export const DAEMON_GRACE_MS = envMs(DAEMON_GRACE_MS_ENV, IS_WINDOWS_HOST ? 15_000 : 10_000);
807
- export const DAEMON_READY_TIMEOUT_MS = envMs(DAEMON_READY_TIMEOUT_MS_ENV, 5_000);
808
- export const DAEMON_CONTROL_TIMEOUT_MS = envMs(DAEMON_CONTROL_TIMEOUT_MS_ENV, IS_WINDOWS_HOST ? 3_000 : 1_500);
809
- export const DAEMON_WATCHDOG_MS = envMs(DAEMON_WATCHDOG_MS_ENV, 5_000);
810
- /** Failed-SSE rolling window (streams) and the failure rate that marks a daemon degraded. */
811
- export const WATCHDOG_SSE_WINDOW = 20;
812
- export const WATCHDOG_SSE_FAIL_RATE = 0.5;
813
- /** Minimum samples before the failed-SSE rate can trigger recovery (avoids single-sample flapping). */
814
- export const WATCHDOG_SSE_MIN_SAMPLES = 5;
815
- /** Respawn backoff: min(2s * 2^n, 60s) + jitter. */
816
- export const RECOVERY_BACKOFF_BASE_MS = 2_000;
817
- export const RECOVERY_BACKOFF_CAP_MS = 60_000;
818
- /** Breaker: 5 straight failures inside 5min halts respawn for 10min (in-process fallback meanwhile). */
819
- export const BREAKER_FAILURES = 5;
820
- export const BREAKER_WINDOW_MS = 5 * 60_000;
821
- export const BREAKER_HALT_MS = 10 * 60_000;
822
- /** Busy bypass: a busy daemon is replaced only after 5min continuous busy with zero forwarded bytes for 60s+. */
823
- export const BUSY_BYPASS_CONTINUOUS_MS = 5 * 60_000;
824
- export const BUSY_BYPASS_QUIET_MS = 60_000;
825
- /** Re-probe of the base port before accepting a walked port (ms). */
826
- export const BASE_PORT_REPROBE_MS = 3_000;
827
141
  export const MAX_BODY_BYTES = 32 * 1024 * 1024;
828
142
  /** Default timeout for upstream headers while proxying a request. */
829
143
  export const UPSTREAM_HEADER_TIMEOUT_MS = 300_000;
144
+
830
145
  // ── Zen transport (opt-in Bun sidecar) ─────────────────────────────────
831
146
  // PI_FREEFLOW_ZEN_TRANSPORT selects the TLS stack that originates the Zen
832
147
  // direct-leg connection. Unset (or anything but "bun-sidecar") keeps the
@@ -841,3 +156,7 @@ export const ZEN_TRANSPORT_ENV = "PI_FREEFLOW_ZEN_TRANSPORT";
841
156
  export const CATALOG_REFRESH_TIMEOUT_MS = 10_000;
842
157
  /** Timeout for probing a relay with a light /v1/models request. */
843
158
  export const PROBE_TIMEOUT_MS = 5_000;
159
+
160
+ // ── Re-exports from split modules for complete backward compatibility ─
161
+ export * from "./config-paths.ts";
162
+ export * from "./opencode-identity.ts";