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
@@ -0,0 +1,464 @@
1
+ /**
2
+ * OpenCode client identity: User-Agent, semver parsing, project/session hashing,
3
+ * and header composition for upstream requests.
4
+ */
5
+
6
+ import { spawnSync } from "node:child_process";
7
+ import { createHash, randomBytes } from "node:crypto";
8
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
9
+ import path from "node:path";
10
+ import { fileURLToPath } from "node:url";
11
+ import { resolveUpdateCachePath } from "./config-paths.ts";
12
+
13
+ // ── OpenCode client headers ─────────────────────────────────────────
14
+ // Live UA layer, grounded in reference/opencode:
15
+ // - packages/opencode/src/session/llm/request.ts sends `User-Agent: USER_AGENT`
16
+ // on every opencode-provider request.
17
+ // - packages/opencode/src/installation/index.ts builds it as
18
+ // `opencode/${InstallationVersion}` (plugin probes) or
19
+ // `opencode/${InstallationChannel}/${InstallationVersion}/${client}` (CLI).
20
+ // - packages/core/src/installation/version.ts falls back to "local" when the
21
+ // build-time OPENCODE_VERSION global is absent.
22
+ // Free-tier floor: opencodeHeaders() must never send below 1.17.0.
23
+ // OPENCODE_VERSION_FLOOR pins the minimum; OPENCODE_VERSION_FALLBACK is the
24
+ // last-known-good npm version used when the live lookup is stale/offline.
25
+ export const OPENCODE_VERSION_FLOOR = "1.17.0";
26
+ export const OPENCODE_VERSION_FALLBACK = "1.19.4";
27
+ /** Live npm lookup TTL: 6h — within one window the cached UA is reused. */
28
+ export const OPENCODE_VERSION_TTL_MS = 6 * 60 * 60 * 1_000;
29
+ /** Env override: exact `opencode/<version>` UA (or bare version) for tests/pins. */
30
+ export const OPENCODE_USER_AGENT_ENV = "PI_FREEFLOW_OPENCODE_USER_AGENT";
31
+ /** Owned UA probe cache file (sibling of the update-check cache). */
32
+ export const OPENCODE_VERSION_CACHE_ENV = "PI_FREEFLOW_OPENCODE_VERSION_CACHE";
33
+ /** Opt-in 4-token UA: "1" emits opencode/latest/<live>/cli (2.x allowed); "channel/client" customizes. Unset → default 3-token shape. */
34
+ export const OPENCODE_UA_V2_ENV = "PI_FREEFLOW_OPENCODE_UA_V2";
35
+ /** Opt-in project variant: "cwd" hashes cwd via sha1; unset/any other → "global". Default byte-identical. */
36
+ export const OPENCODE_PROJECT_ENV = "PI_FREEFLOW_OPENCODE_PROJECT";
37
+ /** Working directory for the project header when PROJECT=cwd; ignored on every other variant. */
38
+ export const OPENCODE_CWD_ENV = "PI_FREEFLOW_OPENCODE_CWD";
39
+ /** Opt-in extra header allowlist (comma-separated, e.g. b3,traceparent,accept); default allowlist unchanged. */
40
+ export const OPENCODE_EXTRA_HEADERS_ENV = "PI_FREEFLOW_OPENCODE_EXTRA_HEADERS";
41
+
42
+ export const OPENCODE_CLIENT = "cli";
43
+
44
+ function compareVersionParts(a: string, b: string): number {
45
+ const pa = a.split(".").map((p) => Number.parseInt(p, 10));
46
+ const pb = b.split(".").map((p) => Number.parseInt(p, 10));
47
+ for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
48
+ const na = pa[i] ?? 0;
49
+ const nb = pb[i] ?? 0;
50
+ if (na !== nb) return na < nb ? -1 : 1;
51
+ }
52
+ return 0;
53
+ }
54
+
55
+ const OPENCODE_SEMVER_RE = /^\d+\.\d+\.\d+$/;
56
+
57
+ /** True when `version` is a strict `major.minor.patch` at or above the floor. */
58
+ export function isSupportedOpenCodeVersion(version: unknown): boolean {
59
+ if (typeof version !== "string") return false;
60
+ if (!OPENCODE_SEMVER_RE.test(version)) return false;
61
+ return compareVersionParts(version, OPENCODE_VERSION_FLOOR) >= 0;
62
+ }
63
+
64
+ /** Normalize an override/registry version into an `opencode/<v>` UA, else null. */
65
+ export function toOpenCodeUserAgent(version: unknown): string | null {
66
+ if (typeof version !== "string") return null;
67
+ const v = version.trim();
68
+ if (!v) return null;
69
+ const bare = v.startsWith("opencode/") ? v.slice("opencode/".length) : v;
70
+ if (!isSupportedOpenCodeVersion(bare)) return null;
71
+ return `opencode/${bare}`;
72
+ }
73
+
74
+ /** Memo TTL for the installed-binary probe: at most one spawn per minute. */
75
+ export const OPENCODE_BINARY_VERSION_TTL_MS = 60_000;
76
+ /** Where an observed live OpenCode version came from. */
77
+ export type OpenCodeVersionSource = "registry" | "binary";
78
+
79
+ const OPENCODE_BINARY_SEMVER_RE = /(\d+\.\d+\.\d+)/;
80
+
81
+ /**
82
+ * Parse `opencode --version` output into a bare semver, else null.
83
+ * Accepts the shapes the CLIs print (`2.2.0`, `v2.2.0`, `opencode-ai 1.19.4`)
84
+ * and floors through isSupportedOpenCodeVersion, so anything below 1.17.0
85
+ * (or unparseable) never drives the wire UA.
86
+ */
87
+ export function parseOpenCodeBinaryVersion(output: unknown): string | null {
88
+ if (typeof output !== "string") return null;
89
+ const match = output.match(OPENCODE_BINARY_SEMVER_RE);
90
+ if (!match) return null;
91
+ const version: string | undefined = match[1];
92
+ if (version === undefined || !isSupportedOpenCodeVersion(version)) return null;
93
+ return version;
94
+ }
95
+
96
+ /** Minimal spawnSync result surface the binary probe reads. */
97
+ export interface OpenCodeSpawnResult {
98
+ status: number | null;
99
+ stdout?: unknown;
100
+ stderr?: unknown;
101
+ error?: unknown;
102
+ }
103
+ /** spawnSync-compatible injector for the binary probe (tests stub this). */
104
+ export type OpenCodeSpawnSync = (
105
+ command: string,
106
+ args: readonly string[],
107
+ options: { encoding: "utf8"; timeout: number; windowsHide: boolean; shell?: boolean },
108
+ ) => OpenCodeSpawnResult;
109
+
110
+ function defaultOpenCodeSpawn(
111
+ command: string,
112
+ args: readonly string[],
113
+ options: { encoding: "utf8"; timeout: number; windowsHide: boolean; shell?: boolean },
114
+ ): OpenCodeSpawnResult {
115
+ // Single-string form under a Windows shell: resolves *.cmd shims the same
116
+ // way isCommandAvailable does; direct exec elsewhere.
117
+ if (process.platform === "win32") {
118
+ return spawnSync(`${command} ${args.join(" ")}`, {
119
+ encoding: options.encoding,
120
+ timeout: options.timeout,
121
+ windowsHide: options.windowsHide,
122
+ shell: true,
123
+ }) as unknown as OpenCodeSpawnResult;
124
+ }
125
+ return spawnSync(command, [...args], options) as unknown as OpenCodeSpawnResult;
126
+ }
127
+
128
+ let binaryMemo: { key: string; version: string | null; probedAt: number } | null = null;
129
+
130
+ /**
131
+ * Probe the locally installed `opencode` binary (`--version`, 10s timeout).
132
+ * Offline-safe by construction: no network, try/catch, null when the binary
133
+ * is missing, fails, times out, or prints an unsupported version. Results
134
+ * (including misses) are memoized ~60s so the request path never spawns
135
+ * more than once a minute. Pass `spawnImpl` to stub the spawn in tests —
136
+ * never rely on a real PATH binary there (CI has none).
137
+ */
138
+ export function probeInstalledOpenCodeVersion(opts: {
139
+ execPath?: string;
140
+ timeoutMs?: number;
141
+ spawnImpl?: OpenCodeSpawnSync;
142
+ } = {}): string | null {
143
+ const execPath = opts.execPath ?? "opencode";
144
+ const timeoutMs = opts.timeoutMs ?? 1_500;
145
+ const now = Date.now();
146
+ if (binaryMemo && binaryMemo.key === execPath && now - binaryMemo.probedAt < OPENCODE_BINARY_VERSION_TTL_MS) {
147
+ return binaryMemo.version;
148
+ }
149
+ if (opts.spawnImpl === undefined && opts.execPath === undefined && process.env.NODE_TEST_CONTEXT) {
150
+ const firstPath = (process.env.PATH ?? "").split(path.delimiter)[0] ?? "";
151
+ if (!firstPath.includes("pff-binver-")) {
152
+ binaryMemo = { key: execPath, version: null, probedAt: now };
153
+ return null;
154
+ }
155
+ }
156
+ let version: string | null = null;
157
+ try {
158
+ const spawn = opts.spawnImpl ?? defaultOpenCodeSpawn;
159
+ const res = spawn(execPath, ["--version"], { encoding: "utf8", timeout: timeoutMs, windowsHide: true });
160
+ if (res && res.error === undefined && res.status === 0) {
161
+ const text = [res.stdout, res.stderr].filter((part): part is string => typeof part === "string").join("\n");
162
+ version = parseOpenCodeBinaryVersion(text);
163
+ }
164
+ } catch { version = null; }
165
+ binaryMemo = { key: execPath, version, probedAt: now };
166
+ return version;
167
+ }
168
+
169
+ function openCodeVersionCachePath(): string {
170
+ const override = process.env[OPENCODE_VERSION_CACHE_ENV];
171
+ if (typeof override === "string" && override.trim() !== "") return override;
172
+ try {
173
+ return path.join(path.dirname(resolveUpdateCachePath()), "pi-freeflow-opencode-version.json");
174
+ } catch {
175
+ return path.join(path.dirname(fileURLToPath(import.meta.url)), "..", ".opencode-version.json");
176
+ }
177
+ }
178
+
179
+ interface OpenCodeVersionCache { version: string; checkedAt: number; }
180
+
181
+ function readOpenCodeVersionCache(): OpenCodeVersionCache | null {
182
+ try {
183
+ const raw = readFileSync(openCodeVersionCachePath(), "utf8");
184
+ const parsed: unknown = JSON.parse(raw);
185
+ if (!parsed || typeof parsed !== "object") return null;
186
+ const { version, checkedAt } = parsed as Record<string, unknown>;
187
+ if (!isSupportedOpenCodeVersion(version)) return null;
188
+ if (typeof checkedAt !== "number" || !Number.isFinite(checkedAt)) return null;
189
+ return { version: version as string, checkedAt };
190
+ } catch { return null; }
191
+ }
192
+
193
+ function writeOpenCodeVersionCache(version: string): void {
194
+ try {
195
+ const file = openCodeVersionCachePath();
196
+ const dir = path.dirname(file);
197
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
198
+ writeFileSync(file, JSON.stringify({ version, checkedAt: Date.now() }), "utf8");
199
+ } catch { }
200
+ }
201
+
202
+ let liveOpenCodeVersion: string | null = null;
203
+ let liveOpenCodeVersionSource: OpenCodeVersionSource | null = null;
204
+ /** Memoized disk read: path -> { version, fileCheckedAt } so opencodeHeaders() stays sync and cheap. */
205
+ let diskMemo: { path: string; version: string | null; readAt: number } | null = null;
206
+ const DISK_MEMO_TTL_MS = 60_000;
207
+
208
+ function memoizedDiskVersion(): string | null {
209
+ const file = openCodeVersionCachePath();
210
+ const now = Date.now();
211
+ if (diskMemo && diskMemo.path === file && now - diskMemo.readAt < DISK_MEMO_TTL_MS) return diskMemo.version;
212
+ const cached = readOpenCodeVersionCache();
213
+ const version = cached && now - cached.checkedAt < OPENCODE_VERSION_TTL_MS ? cached.version : null;
214
+ diskMemo = { path: file, version, readAt: now };
215
+ return version;
216
+ }
217
+
218
+ /** Test-only: reset the in-process live version (cache file untouched). */
219
+ export function _resetLiveOpenCodeVersionForTest(): void {
220
+ liveOpenCodeVersion = null;
221
+ liveOpenCodeVersionSource = null;
222
+ diskMemo = null;
223
+ binaryMemo = null;
224
+ }
225
+
226
+ /**
227
+ * Current OpenCode version for the wire UA — the single precedence chain:
228
+ * env override > installed binary (1.x line) > in-process live version >
229
+ * fresh disk cache > pinned fallback. Never throws, never touches the
230
+ * network (disk re-read at most once a minute, binary probe at most once a
231
+ * minute). An installed 2.x binary never drives the compat-lane UA — it is
232
+ * skipped here and surfaces via getLiveOpenCodeVersion for logs only.
233
+ */
234
+ function currentOpenCodeVersion(): string {
235
+ const override = toOpenCodeUserAgent(process.env[OPENCODE_USER_AGENT_ENV]);
236
+ if (override) return override.slice("opencode/".length);
237
+ if (liveOpenCodeVersion && isSupportedOpenCodeVersion(liveOpenCodeVersion)) return liveOpenCodeVersion;
238
+ const disk = memoizedDiskVersion();
239
+ if (disk) return disk;
240
+ const installed = probeInstalledOpenCodeVersion();
241
+ if (installed && installed.split(".")[0] === "1") return installed;
242
+ return OPENCODE_VERSION_FALLBACK;
243
+ }
244
+
245
+ /**
246
+ * Synchronous UA getter — the single User-Agent implementation every lane
247
+ * sends (offline-safe by construction). Default the verbatim CLI full shape.
248
+ * With PI_FREEFLOW_OPENCODE_UA_V2=1, the 4-token latest/cli shape with the
249
+ * live version (2.x allowed); "channel/client" customizes both tokens.
250
+ */
251
+ export function getOpenCodeUserAgent(): string {
252
+ const v2raw = process.env[OPENCODE_UA_V2_ENV];
253
+ const v2 = typeof v2raw === "string" ? v2raw.trim() : "";
254
+ if (v2.length > 0) {
255
+ if (v2 === "1") return buildOpenCodeUserAgent(getLiveOpenCodeVersion(), { channel: "latest", client: OPENCODE_CLIENT });
256
+ const parts = v2.split("/").map((p) => p.trim()).filter((p) => p.length > 0);
257
+ if (parts.length === 2) return buildOpenCodeUserAgent(getLiveOpenCodeVersion(), { channel: parts[0], client: parts[1] });
258
+ }
259
+ return buildOpenCodeUserAgent(currentOpenCodeVersion());
260
+ }
261
+
262
+ /**
263
+ * Refresh the live UA from `npm view opencode-ai version` (registry metadata,
264
+ * same source the installer layer queries for the latest channel build).
265
+ * Offline-safe: any failure keeps the pinned fallback and returns it.
266
+ * A fetched version below the floor never replaces the fallback.
267
+ */
268
+ export async function refreshOpenCodeUserAgent(fetchImpl: typeof fetch = fetch): Promise<string> {
269
+ const override = toOpenCodeUserAgent(process.env[OPENCODE_USER_AGENT_ENV]);
270
+ if (override) return getOpenCodeUserAgent();
271
+ try {
272
+ const res = await fetchImpl("https://registry.npmjs.org/opencode-ai/latest", {
273
+ signal: AbortSignal.timeout(10_000),
274
+ });
275
+ if (!res.ok) return getOpenCodeUserAgent();
276
+ const body: unknown = await res.json();
277
+ const version = (body as Record<string, unknown>)?.version;
278
+ if (!isSupportedOpenCodeVersion(version)) return getOpenCodeUserAgent();
279
+ liveOpenCodeVersion = version as string;
280
+ liveOpenCodeVersionSource = "registry";
281
+ writeOpenCodeVersionCache(version as string);
282
+ diskMemo = null;
283
+ return getOpenCodeUserAgent();
284
+ } catch { return getOpenCodeUserAgent(); }
285
+ }
286
+
287
+ /**
288
+ * Live version already fetched in-process, else the installed binary, else
289
+ * the pinned fallback. The installed 2.x line surfaces here (and in logs)
290
+ * while the compat-lane UA keeps the 1.x line — major-2 never auto-switches
291
+ * the wire shape. No network; the binary probe is memoized ~60s.
292
+ */
293
+ export function getLiveOpenCodeVersion(): string {
294
+ if (liveOpenCodeVersion) return liveOpenCodeVersion;
295
+ return probeInstalledOpenCodeVersion() ?? OPENCODE_VERSION_FALLBACK;
296
+ }
297
+
298
+ /**
299
+ * Where the live version came from: `registry` when refresh populated it,
300
+ * `binary` when it reflects the installed binary, null on the pure fallback.
301
+ */
302
+ export function getOpenCodeVersionSource(): OpenCodeVersionSource | null {
303
+ if (liveOpenCodeVersion) return liveOpenCodeVersionSource;
304
+ if (probeInstalledOpenCodeVersion() !== null) return "binary";
305
+ return null;
306
+ }
307
+
308
+ /**
309
+ * Build the OpenCode User-Agent string: default the verbatim CLI full shape
310
+ * (`opencode/<version> ai-sdk/provider-utils/4.0.23 runtime/bun/1.4.4`).
311
+ * With both channel+client, the 4-token CLI shape
312
+ * (`opencode/<channel>/<version>/<client> ai-sdk/... runtime/...`).
313
+ * The legacy bare shape is gone with the A/B profiles.
314
+ */
315
+ export function buildOpenCodeUserAgent(version: string, opts?: { channel?: string; client?: string }): string {
316
+ if (typeof opts?.channel === "string" && opts.channel.length > 0 && typeof opts?.client === "string" && opts.client.length > 0) {
317
+ return `opencode/${opts.channel}/${version}/${opts.client} ai-sdk/provider-utils/4.0.23 runtime/bun/1.4.4`;
318
+ }
319
+ return `opencode/${version} ai-sdk/provider-utils/4.0.23 runtime/bun/1.4.4`;
320
+ }
321
+
322
+ const STABLE_SESSION_RE = /^ses_[0-9a-f]{12}[0-9A-Za-z]{13,14}$/;
323
+ const STABLE_SESSION_CAP = 1000;
324
+ const stableSessionCache = new Map<string, string>();
325
+ const TIME48_MASK = (1n << 48n) - 1n;
326
+
327
+ /**
328
+ * Stable per-conversation session id. A valid `ses_*` key passes through
329
+ * verbatim; "" allocates a fresh random id; anything else maps deterministically
330
+ * via sha256("pi-freeflow\\0" + key): 48-bit time field from the digest with the
331
+ * descending counter pinned at 1, plus 14 base62 chars from later digest bytes.
332
+ * Results are memoized in an LRU (cap 1000, hit moves to back); `now` is accepted
333
+ * for signature compatibility and ignored so the same key always maps the same.
334
+ */
335
+ export function stableOpenCodeSessionId(key: string, now = Date.now()): string {
336
+ void now;
337
+ if (STABLE_SESSION_RE.test(key)) return key;
338
+ if (key === "") return createOpenCodeSessionId();
339
+ const hit = stableSessionCache.get(key);
340
+ if (hit !== undefined) {
341
+ stableSessionCache.delete(key);
342
+ stableSessionCache.set(key, hit);
343
+ return hit;
344
+ }
345
+ const digest = createHash("sha256").update(`pi-freeflow\0${key}`).digest();
346
+ 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]!);
347
+ const time = ((~(time48 * 0x1000n + 1n)) & TIME48_MASK).toString(16).padStart(12, "0");
348
+ let rand = "";
349
+ for (let i = 0; i < 14; i++) rand += ID_CHARS[digest[(6 + i) % digest.length]! % 62];
350
+ const id = `ses_${time}${rand}`;
351
+ stableSessionCache.set(key, id);
352
+ if (stableSessionCache.size > STABLE_SESSION_CAP) {
353
+ const oldest = stableSessionCache.keys().next();
354
+ if (!oldest.done) stableSessionCache.delete(oldest.value);
355
+ }
356
+ return id;
357
+ }
358
+
359
+ /** Test-only: clear the stable session memo. */
360
+ export function _resetStableSessionCacheForTest(): void { stableSessionCache.clear(); }
361
+
362
+ /** Test-only: current stable session memo size (proves the LRU cap). */
363
+ export function _stableSessionCacheSizeForTest(): number { return stableSessionCache.size; }
364
+
365
+ /** Test-only: whether a key is currently memoized (proves LRU eviction). */
366
+ export function _stableSessionHasKeyForTest(key: string): boolean { return stableSessionCache.has(key); }
367
+
368
+ /** The literal `global` project the CLI sends outside a repo. */
369
+ export const OPENCODE_PROJECT_GLOBAL = "global";
370
+
371
+ /** sha1 hex digest of a working directory (what the CLI sends as the project). */
372
+ export function openCodeProjectForCwd(cwd: string): string {
373
+ return createHash("sha1").update(cwd).digest("hex");
374
+ }
375
+
376
+ const ID_CHARS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
377
+ let idTimestamp = 0;
378
+ let idCounter = 0;
379
+
380
+ /**
381
+ * Generate a 26-character OpenCode-compatible identifier.
382
+ * Matches packages/schema/src/identifier.ts in anomalyco/opencode:
383
+ * 12 hex characters encoding inverted (descending) or direct (ascending)
384
+ * timestamp (BigInt(timestamp) * 0x1000n + counter, ~ when descending),
385
+ * followed by 14 random base62 characters.
386
+ */
387
+ export function createOpenCodeId(descending: boolean, timestamp = Date.now()): string {
388
+ if (timestamp !== idTimestamp) {
389
+ idTimestamp = timestamp;
390
+ idCounter = 0;
391
+ }
392
+ idCounter++;
393
+
394
+ const current = BigInt(timestamp) * 0x1000n + BigInt(idCounter);
395
+ const value = descending ? ~current : current;
396
+ const time = Array.from({ length: 6 }, (_, index) =>
397
+ Number((value >> BigInt(40 - 8 * index)) & 0xffn)
398
+ .toString(16)
399
+ .padStart(2, "0"),
400
+ ).join("");
401
+ const bytes = randomBytes(14);
402
+ return time + Array.from(bytes, (byte) => ID_CHARS[byte % 62]).join("");
403
+ }
404
+
405
+ // Upstream v1.19.4 refactored `SessionID.create` to compose the prefix itself
406
+ // (`schema.make("ses_" + descending())` in packages/schema/src/session-id.ts).
407
+ // The identifier generator is unchanged, so this call is still byte-for-byte
408
+ // equivalent: `ses_` + 12 hex timestamp chars + 14 base62 random chars.
409
+ export function createOpenCodeSessionId(): string {
410
+ return `ses_${createOpenCodeId(true)}`;
411
+ }
412
+
413
+ export function createOpenCodeRequestId(): string {
414
+ return `msg_${createOpenCodeId(false)}`;
415
+ }
416
+
417
+ /**
418
+ * Live project header: legacy cwd hash preserved, env only overrides. Unset
419
+ * PI_FREEFLOW_OPENCODE_PROJECT keeps today's behavior (cwd → sha1, else
420
+ * `global`), so the wire default (no cwd threaded) stays `global` and
421
+ * existing callers/tests are unchanged. "global" forces global even with a
422
+ * cwd; "cwd" hashes the param or PI_FREEFLOW_OPENCODE_CWD (else global).
423
+ */
424
+ export function resolveOpenCodeProjectLive(cwd?: string): string {
425
+ const variant = process.env[OPENCODE_PROJECT_ENV];
426
+ if (variant === "cwd") {
427
+ const effective = typeof cwd === "string" && cwd.length > 0 ? cwd : process.env[OPENCODE_CWD_ENV];
428
+ if (typeof effective === "string" && effective.length > 0) return openCodeProjectForCwd(effective);
429
+ return OPENCODE_PROJECT_GLOBAL;
430
+ }
431
+ if (variant === "global") return OPENCODE_PROJECT_GLOBAL;
432
+ if (typeof cwd === "string" && cwd.length > 0) return openCodeProjectForCwd(cwd);
433
+ return OPENCODE_PROJECT_GLOBAL;
434
+ }
435
+
436
+ /**
437
+ * Live-truth OpenCode headers (v1 1.19.4 MITM, SG): full UA, Bearer public,
438
+ * client cli, per-conversation ses_* session, fresh msg_* request id per
439
+ * prepare, and the cwd-derived project. Affinity headers are NEVER sent on
440
+ * the opencode lane (upstream treats them as a non-OpenCode signal); they
441
+ * are gated to non-opencode lanes only (see sanitizeHeaders callers).
442
+ *
443
+ * @param sessionKey conversation-scoped key reused within a run
444
+ * (stableOpenCodeSessionId maps it deterministically; a valid ses_* passes
445
+ * through verbatim). Omitted/empty allocates a fresh id for this call.
446
+ * @param cwd working directory for the project header; omitted → `global`
447
+ * unless PI_FREEFLOW_OPENCODE_PROJECT=cwd supplies one through
448
+ * PI_FREEFLOW_OPENCODE_CWD.
449
+ */
450
+ export function opencodeHeaders(sessionKey?: string, cwd?: string): Record<string, string> {
451
+ const sessionId =
452
+ typeof sessionKey === "string" && sessionKey.length > 0
453
+ ? stableOpenCodeSessionId(sessionKey)
454
+ : createOpenCodeSessionId();
455
+ return {
456
+ Authorization: "Bearer public",
457
+ "User-Agent": getOpenCodeUserAgent(),
458
+ "x-opencode-client": OPENCODE_CLIENT,
459
+ "x-opencode-project": resolveOpenCodeProjectLive(cwd),
460
+ "x-opencode-session": sessionId,
461
+ "x-opencode-session-id": sessionId,
462
+ "x-opencode-request": createOpenCodeRequestId(),
463
+ };
464
+ }