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,365 @@
1
+ /**
2
+ * Pure in-memory relay pool export/import codec for pi-freeflow
3
+ */
4
+
5
+ import type { KnownRelay, RelayMode, RelayState } from "./types.ts";
6
+ import { ensureRelay, validateRelayUrl } from "./relay-state.ts";
7
+
8
+ /** Envelope marker identifying a pi-freeflow relay export file. */
9
+ export const EXPORT_KIND = "pi-freeflow/relay-export" as const;
10
+ /** Current relay export file version. Bump on any envelope breaking change. */
11
+ export const EXPORT_VERSION = 1;
12
+ /** Default file name used by `/freeflow export` when no path is given. */
13
+ export const EXPORT_DEFAULT_FILENAME = "freeflow-relays.json";
14
+ /** Hard cap on accepted import file size (1 MiB). */
15
+ export const EXPORT_MAX_BYTES = 1_048_576;
16
+
17
+ /** A parsed relay export file envelope. */
18
+ export interface RelayExportArtifact {
19
+ kind: "pi-freeflow/relay-export";
20
+ version: 1;
21
+ exportedAt: string;
22
+ state: {
23
+ mode?: RelayMode;
24
+ enabled: boolean;
25
+ url: string;
26
+ relays: KnownRelay[];
27
+ hideWidget?: boolean;
28
+ };
29
+ }
30
+
31
+ /** One import candidate entry that was skipped instead of aborting the file. */
32
+ export interface RelayImportSkipped {
33
+ index: number;
34
+ url: string;
35
+ reason: string;
36
+ }
37
+
38
+ /** RelayState subset carried by an import file (pool scope only). */
39
+ export type RelayImportFragment = Pick<
40
+ RelayState,
41
+ "mode" | "enabled" | "url" | "relays" | "hideWidget"
42
+ >;
43
+
44
+ /** Result of planning an import against the current pool. */
45
+ export interface RelayImportPlanResult {
46
+ next: RelayState;
47
+ added: number;
48
+ updated: number;
49
+ removed: number;
50
+ skipped: RelayImportSkipped[];
51
+ }
52
+
53
+ /** Alias kept for the cross-slice contract name. */
54
+ export type PlanResult = RelayImportPlanResult;
55
+
56
+ /**
57
+ * Build an export artifact from live relay state. Passwords are stripped by
58
+ * default (the key is absent, not null); pass `{ includeSecrets: true }` to
59
+ * carry them verbatim.
60
+ */
61
+ export function buildRelayExport(
62
+ state: RelayState,
63
+ opts?: { includeSecrets?: boolean },
64
+ ): RelayExportArtifact {
65
+ const includeSecrets = opts?.includeSecrets === true;
66
+ return {
67
+ kind: EXPORT_KIND,
68
+ version: EXPORT_VERSION,
69
+ exportedAt: new Date().toISOString(),
70
+ state: {
71
+ mode: state.mode,
72
+ enabled: state.enabled,
73
+ url: state.url,
74
+ relays: state.relays.map((r) => {
75
+ const out: KnownRelay = { url: r.url };
76
+ if (r.label !== undefined) out.label = r.label;
77
+ if (r.addedAt !== undefined) out.addedAt = r.addedAt;
78
+ if (
79
+ includeSecrets &&
80
+ typeof r.auth === "string" &&
81
+ r.auth.length > 0
82
+ ) {
83
+ out.auth = r.auth;
84
+ }
85
+ return out;
86
+ }),
87
+ hideWidget: state.hideWidget,
88
+ },
89
+ };
90
+ }
91
+
92
+ /**
93
+ * Parse raw import file text into a relay fragment. Throws on envelope
94
+ * failure (oversize, bad JSON, wrong kind/version, missing pool); per-entry
95
+ * problems fail closed to `skipped` so one bad relay never wipes the pool.
96
+ */
97
+ export function parseRelayImport(raw: string): {
98
+ fragment: RelayImportFragment;
99
+ skipped: RelayImportSkipped[];
100
+ warnings: string[];
101
+ } {
102
+ if (raw.length > EXPORT_MAX_BYTES) {
103
+ throw new Error(
104
+ `Relay import file too large (${raw.length} bytes, limit ${EXPORT_MAX_BYTES})`,
105
+ );
106
+ }
107
+ let doc: unknown;
108
+ try {
109
+ doc = JSON.parse(raw);
110
+ } catch {
111
+ throw new Error("Relay import file is not valid JSON");
112
+ }
113
+ const envelope =
114
+ typeof doc === "object" && doc !== null
115
+ ? (doc as Record<string, unknown>)
116
+ : null;
117
+ if (envelope?.kind !== EXPORT_KIND) {
118
+ throw new Error("File is not a relay export (bad kind marker)");
119
+ }
120
+ if (
121
+ typeof envelope.version !== "number" ||
122
+ !Number.isInteger(envelope.version) ||
123
+ envelope.version !== EXPORT_VERSION
124
+ ) {
125
+ throw new Error(
126
+ `Unsupported relay export version (${String(envelope.version)}); this build reads version ${EXPORT_VERSION}`,
127
+ );
128
+ }
129
+ const rawState =
130
+ typeof envelope.state === "object" && envelope.state !== null
131
+ ? (envelope.state as Record<string, unknown>)
132
+ : null;
133
+ if (!rawState || !Array.isArray(rawState.relays)) {
134
+ throw new Error("File is not a relay export (state pool is missing)");
135
+ }
136
+
137
+ const warnings: string[] = [];
138
+ const skipped: RelayImportSkipped[] = [];
139
+
140
+ const rawMode = rawState.mode;
141
+ const mode: RelayMode =
142
+ rawMode === "on" || rawMode === "off" || rawMode === "auto" || rawMode === "spread"
143
+ ? rawMode
144
+ : "auto";
145
+ if (rawMode !== "on" && rawMode !== "off" && rawMode !== "auto" && rawMode !== "spread") {
146
+ warnings.push("Unknown relay mode in file; using automatic mode.");
147
+ }
148
+
149
+ const rawEnabled = rawState.enabled;
150
+ const enabled =
151
+ typeof rawEnabled === "boolean"
152
+ ? rawEnabled
153
+ : mode === "on"
154
+ ? true
155
+ : mode === "off"
156
+ ? false
157
+ : (rawState.relays as unknown[]).length > 0;
158
+
159
+ const rawUrl = rawState.url;
160
+ let url = "";
161
+ if (typeof rawUrl === "string" && rawUrl.trim()) {
162
+ const trimmed = rawUrl.trim();
163
+ if (validateRelayUrl(trimmed).ok) {
164
+ url = trimmed;
165
+ } else {
166
+ warnings.push("Active relay address in file is not usable; ignoring it.");
167
+ }
168
+ }
169
+
170
+ const hideWidget = rawState.hideWidget === true;
171
+ const relays: KnownRelay[] = [];
172
+ const seen = new Set<string>();
173
+ const entries = rawState.relays as unknown[];
174
+ for (let index = 0; index < entries.length; index++) {
175
+ const entry = entries[index];
176
+ if (typeof entry !== "object" || entry === null) {
177
+ skipped.push({ index, url: "", reason: "entry is not an object" });
178
+ continue;
179
+ }
180
+ const record = entry as Record<string, unknown>;
181
+ const trimmedUrl =
182
+ typeof record.url === "string" ? record.url.trim() : "";
183
+ if (!trimmedUrl) {
184
+ skipped.push({ index, url: "", reason: "missing relay address" });
185
+ continue;
186
+ }
187
+ const check = validateRelayUrl(trimmedUrl);
188
+ if (!check.ok) {
189
+ skipped.push({ index, url: trimmedUrl, reason: check.reason });
190
+ continue;
191
+ }
192
+ // Exact trimmed-address dedupe (first wins), mirroring ensureRelay.
193
+ if (seen.has(trimmedUrl)) {
194
+ skipped.push({ index, url: trimmedUrl, reason: "duplicate relay address" });
195
+ continue;
196
+ }
197
+ seen.add(trimmedUrl);
198
+ const candidate: KnownRelay = { url: trimmedUrl };
199
+ if (typeof record.label === "string") {
200
+ const cleanLabel = record.label.trim();
201
+ if (cleanLabel && cleanLabel !== "manual") {
202
+ if (cleanLabel.length > 64) {
203
+ candidate.label = cleanLabel.slice(0, 64);
204
+ warnings.push(
205
+ `Short name for ${trimmedUrl} truncated to 64 characters.`,
206
+ );
207
+ } else {
208
+ candidate.label = cleanLabel;
209
+ }
210
+ }
211
+ }
212
+ if (
213
+ typeof record.addedAt === "string" &&
214
+ Number.isNaN(Date.parse(record.addedAt)) === false
215
+ ) {
216
+ candidate.addedAt = record.addedAt;
217
+ }
218
+ if (
219
+ typeof record.auth === "string" &&
220
+ record.auth.length > 0
221
+ ) {
222
+ candidate.auth = record.auth;
223
+ } else if (record.auth !== undefined) {
224
+ warnings.push(`Password for ${trimmedUrl} is not usable; ignoring it.`);
225
+ }
226
+ relays.push(candidate);
227
+ }
228
+
229
+ return {
230
+ fragment: { mode, enabled, url, relays, hideWidget },
231
+ skipped,
232
+ warnings,
233
+ };
234
+ }
235
+
236
+ function copyRelayEntry(r: KnownRelay): KnownRelay {
237
+ const out: KnownRelay = { url: r.url };
238
+ if (r.label !== undefined) out.label = r.label;
239
+ if (r.addedAt !== undefined) out.addedAt = r.addedAt;
240
+ if (r.auth !== undefined) out.auth = r.auth;
241
+ return out;
242
+ }
243
+
244
+ /**
245
+ * Plan an import against the current pool without touching disk or prompting.
246
+ * Merge folds the file pool into the current one via ensureRelay semantics and
247
+ * keeps the current mode/switch/widget; replace installs the file pool fresh.
248
+ */
249
+ export function planRelayImport(
250
+ current: RelayState,
251
+ fragment: RelayImportFragment,
252
+ opts: { mode: "merge" | "replace" },
253
+ ): RelayImportPlanResult {
254
+ if (opts.mode === "replace") {
255
+ const installed: KnownRelay[] = [];
256
+ const seen = new Set<string>();
257
+ const skipped: RelayImportSkipped[] = [];
258
+ const source = fragment.relays ?? [];
259
+ for (let index = 0; index < source.length; index++) {
260
+ const entry = source[index];
261
+ const trimmedUrl =
262
+ typeof entry?.url === "string" ? entry.url.trim() : "";
263
+ if (!trimmedUrl || !validateRelayUrl(trimmedUrl).ok) {
264
+ skipped.push({
265
+ index,
266
+ url: trimmedUrl,
267
+ reason: "relay address is not usable",
268
+ });
269
+ continue;
270
+ }
271
+ if (seen.has(trimmedUrl)) {
272
+ skipped.push({
273
+ index,
274
+ url: trimmedUrl,
275
+ reason: "duplicate relay address",
276
+ });
277
+ continue;
278
+ }
279
+ seen.add(trimmedUrl);
280
+ installed.push(copyRelayEntry({ ...entry, url: trimmedUrl }));
281
+ }
282
+ // Empty file pool installs nothing: fail closed here (not just in the
283
+ // command handler) so no caller can turn zero usable addresses into a wipe.
284
+ if (installed.length === 0) {
285
+ return {
286
+ next: { ...current, relays: current.relays.map(copyRelayEntry) },
287
+ added: 0,
288
+ updated: 0,
289
+ removed: 0,
290
+ skipped,
291
+ };
292
+ }
293
+ const installedUrls = new Set(installed.map((r) => r.url));
294
+ const active =
295
+ fragment.url && installedUrls.has(fragment.url)
296
+ ? fragment.url
297
+ : (installed[0]?.url ?? "");
298
+ return {
299
+ next: {
300
+ mode: fragment.mode ?? "auto",
301
+ enabled: fragment.enabled,
302
+ url: active,
303
+ relays: installed,
304
+ hideWidget: fragment.hideWidget === true,
305
+ },
306
+ added: installed.length,
307
+ updated: 0,
308
+ removed: Math.max(0, current.relays.length - installed.length),
309
+ skipped,
310
+ };
311
+ }
312
+
313
+ const next: RelayState = {
314
+ ...current,
315
+ relays: current.relays.map(copyRelayEntry),
316
+ };
317
+ let added = 0;
318
+ let updated = 0;
319
+ const skipped: RelayImportSkipped[] = [];
320
+ const source = fragment.relays ?? [];
321
+ for (let index = 0; index < source.length; index++) {
322
+ const entry = source[index];
323
+ const trimmedUrl =
324
+ typeof entry?.url === "string" ? entry.url.trim() : "";
325
+ if (!trimmedUrl) {
326
+ skipped.push({ index, url: "", reason: "missing relay address" });
327
+ continue;
328
+ }
329
+ let relay: KnownRelay | undefined;
330
+ try {
331
+ const already = next.relays.some((r) => r.url === trimmedUrl);
332
+ relay = ensureRelay(next, trimmedUrl, entry.label);
333
+ if (already) {
334
+ updated += 1;
335
+ } else {
336
+ added += 1;
337
+ }
338
+ } catch (err) {
339
+ skipped.push({
340
+ index,
341
+ url: trimmedUrl,
342
+ reason: err instanceof Error ? err.message : "relay address rejected",
343
+ });
344
+ continue;
345
+ }
346
+ // File password overwrites; an absent file password keeps the stored one.
347
+ if (typeof entry.auth === "string" && entry.auth.length > 0) {
348
+ relay.auth = entry.auth;
349
+ }
350
+ if (
351
+ typeof entry.addedAt === "string" &&
352
+ Number.isNaN(Date.parse(entry.addedAt)) === false
353
+ ) {
354
+ relay.addedAt = entry.addedAt;
355
+ }
356
+ }
357
+ const mergedUrls = new Set(next.relays.map((r) => r.url));
358
+ if (!mergedUrls.has(next.url)) {
359
+ next.url =
360
+ fragment.url && mergedUrls.has(fragment.url)
361
+ ? fragment.url
362
+ : (next.relays[0]?.url ?? "");
363
+ }
364
+ return { next, added, updated, removed: 0, skipped };
365
+ }