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.
- package/CHANGELOG.md +29 -0
- package/README.md +11 -13
- package/package.json +1 -1
- package/src/catalog.ts +38 -14
- package/src/client-recovery.ts +238 -0
- package/src/client-staging.ts +316 -0
- package/src/client.ts +117 -472
- package/src/cline-accounts.ts +26 -1223
- package/src/cline-device-auth.ts +5 -3
- package/src/cline-pool.ts +691 -0
- package/src/cline-roll.ts +642 -0
- package/src/commands-cline.ts +283 -0
- package/src/commands-relay.ts +808 -0
- package/src/commands-startup.ts +264 -0
- package/src/commands.ts +119 -1202
- package/src/config-paths.ts +254 -0
- package/src/config.ts +6 -687
- package/src/daemon.ts +16 -1
- package/src/deploy.ts +3 -1
- package/src/fingerprint-env.ts +334 -0
- package/src/fingerprint-tools.ts +348 -0
- package/src/index.ts +10 -6
- package/src/lane-translation.ts +263 -0
- package/src/models.ts +70 -112
- package/src/opencode-fingerprint.ts +29 -1310
- package/src/opencode-identity.ts +464 -0
- package/src/proxy-lanes.ts +1125 -0
- package/src/proxy-ports.ts +317 -0
- package/src/proxy.ts +74 -1385
- package/src/relay-codec.ts +365 -0
- package/src/relay-state.ts +10 -367
- package/src/relay.ts +78 -31
- package/src/sse-aggregator.ts +659 -0
- package/src/stream-cloak.ts +882 -0
- package/src/stream-pipe.ts +22 -871
- package/src/system-ca-fetch.ts +15 -4
- package/src/token.ts +7 -2
- package/src/tool-args.ts +10 -3
- package/src/tool-translation.ts +1 -236
- package/src/toolcall-audit.ts +20 -7
- package/src/update-checker.ts +17 -1
- package/src/update-core.ts +5 -2
- package/src/updater.ts +8 -3
- package/src/upstream-health.ts +22 -2
- package/src/zen-sidecar.ts +1 -0
- 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
|
+
}
|