pi-freeflow 1.44.1 → 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 +23 -0
- package/README.md +11 -13
- package/package.json +1 -1
- package/src/catalog.ts +38 -14
- package/src/client-recovery.ts +4 -1
- package/src/client-staging.ts +53 -3
- package/src/client.ts +37 -19
- package/src/cline-accounts.ts +7 -4
- package/src/cline-device-auth.ts +5 -3
- package/src/cline-pool.ts +176 -126
- package/src/cline-roll.ts +71 -38
- package/src/commands-relay.ts +2 -2
- package/src/commands-startup.ts +4 -1
- package/src/commands.ts +28 -6
- package/src/daemon.ts +16 -1
- package/src/deploy.ts +3 -1
- package/src/fingerprint-tools.ts +1 -1
- package/src/index.ts +10 -6
- package/src/lane-translation.ts +22 -3
- package/src/models.ts +70 -112
- package/src/opencode-fingerprint.ts +5 -2
- package/src/opencode-identity.ts +10 -3
- package/src/proxy-lanes.ts +15 -3
- package/src/proxy-ports.ts +2 -2
- package/src/proxy.ts +29 -14
- package/src/relay-state.ts +9 -4
- package/src/relay.ts +78 -31
- package/src/sse-aggregator.ts +1 -0
- package/src/stream-cloak.ts +7 -4
- package/src/stream-pipe.ts +12 -2
- package/src/system-ca-fetch.ts +15 -4
- package/src/token.ts +7 -2
- package/src/tool-args.ts +10 -3
- 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
package/src/cline-pool.ts
CHANGED
|
@@ -37,7 +37,57 @@ export const CLINE_POOL_FILE = resolveClinePoolPath();
|
|
|
37
37
|
* overridden from the backup.
|
|
38
38
|
*/
|
|
39
39
|
export const CLINE_POOL_BACKUP_FILE = `${CLINE_POOL_FILE}.bak`;
|
|
40
|
+
export const CLINE_POOL_LOCK_FILE = `${CLINE_POOL_FILE}.lock`;
|
|
41
|
+
const CLINE_LOCK_STALE_MS = 30_000;
|
|
40
42
|
|
|
43
|
+
export function tryAcquireClineLock(depth = 0): boolean {
|
|
44
|
+
if (depth >= 3) return false;
|
|
45
|
+
try {
|
|
46
|
+
fs.writeFileSync(CLINE_POOL_LOCK_FILE, String(process.pid), { flag: "wx" });
|
|
47
|
+
return true;
|
|
48
|
+
} catch (e) {
|
|
49
|
+
const code = (e as NodeJS.ErrnoException | null)?.code;
|
|
50
|
+
if (code !== "EEXIST") {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
let stale = false;
|
|
54
|
+
try {
|
|
55
|
+
stale = Date.now() - fs.statSync(CLINE_POOL_LOCK_FILE).mtimeMs > CLINE_LOCK_STALE_MS;
|
|
56
|
+
} catch {
|
|
57
|
+
return tryAcquireClineLock(depth + 1);
|
|
58
|
+
}
|
|
59
|
+
if (!stale) {
|
|
60
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 20);
|
|
61
|
+
return tryAcquireClineLock(depth + 1);
|
|
62
|
+
}
|
|
63
|
+
try {
|
|
64
|
+
fs.rmSync(CLINE_POOL_LOCK_FILE, { force: true });
|
|
65
|
+
} catch {
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
return tryAcquireClineLock(depth + 1);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function releaseClineLock(): void {
|
|
73
|
+
try { fs.rmSync(CLINE_POOL_LOCK_FILE, { force: true }); } catch { }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function withClineLock<T>(fn: () => T): T {
|
|
77
|
+
const locked = tryAcquireClineLock();
|
|
78
|
+
if (!locked) {
|
|
79
|
+
logWarn("cline pool lock held — proceeding without lock", {
|
|
80
|
+
lock: CLINE_POOL_LOCK_FILE,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
try {
|
|
84
|
+
return fn();
|
|
85
|
+
} finally {
|
|
86
|
+
if (locked) {
|
|
87
|
+
releaseClineLock();
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
41
91
|
/** One saved login slot. The token is only ever sent as an explicit bearer header. */
|
|
42
92
|
export interface ClineAccount {
|
|
43
93
|
slot: string;
|
|
@@ -336,8 +386,7 @@ export function keepLargestBackup(raw: string): void {
|
|
|
336
386
|
backupCount = -1;
|
|
337
387
|
}
|
|
338
388
|
if (count <= backupCount) return;
|
|
339
|
-
|
|
340
|
-
try { fs.chmodSync(CLINE_POOL_BACKUP_FILE, 0o600); } catch { }
|
|
389
|
+
writeFileAtomic(CLINE_POOL_BACKUP_FILE, raw, { mode: 0o600 });
|
|
341
390
|
} catch { }
|
|
342
391
|
}
|
|
343
392
|
|
|
@@ -367,8 +416,7 @@ export function forgetAccountInBackup(removed: ClineAccount): void {
|
|
|
367
416
|
});
|
|
368
417
|
if (kept.length === doc.accounts.length) return;
|
|
369
418
|
doc.accounts = kept;
|
|
370
|
-
|
|
371
|
-
try { fs.chmodSync(CLINE_POOL_BACKUP_FILE, 0o600); } catch { }
|
|
419
|
+
writeFileAtomic(CLINE_POOL_BACKUP_FILE, JSON.stringify(doc, null, 2), { mode: 0o600 });
|
|
372
420
|
} catch { }
|
|
373
421
|
}
|
|
374
422
|
|
|
@@ -424,19 +472,23 @@ export function redactedToken(token: string): string {
|
|
|
424
472
|
* slot that no longer exists is left alone rather than resurrected.
|
|
425
473
|
*/
|
|
426
474
|
export function patchAccountOnDisk(slot: string, patch: Partial<ClineAccount>): void {
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
475
|
+
withClineLock(() => {
|
|
476
|
+
const live = readPoolFile();
|
|
477
|
+
const target = live.accounts.find((a) => a.slot === slot);
|
|
478
|
+
if (!target) return;
|
|
479
|
+
Object.assign(target, patch);
|
|
480
|
+
savePool(live);
|
|
481
|
+
});
|
|
432
482
|
}
|
|
433
483
|
|
|
434
484
|
/** Record which slot served a turn, without clobbering concurrent edits. */
|
|
435
485
|
export function markActiveSlotOnDisk(slot: string): void {
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
live.
|
|
439
|
-
|
|
486
|
+
withClineLock(() => {
|
|
487
|
+
const live = readPoolFile();
|
|
488
|
+
if (!live.accounts.some((a) => a.slot === slot)) return;
|
|
489
|
+
live.activeSlot = slot;
|
|
490
|
+
savePool(live);
|
|
491
|
+
});
|
|
440
492
|
}
|
|
441
493
|
|
|
442
494
|
/**
|
|
@@ -448,19 +500,21 @@ export function markActiveSlotOnDisk(slot: string): void {
|
|
|
448
500
|
* resurrected. The row carries counters only — never token or identity.
|
|
449
501
|
*/
|
|
450
502
|
export function recordClineUsageOnDisk(slot: string, modelId: string): void {
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
}
|
|
463
|
-
|
|
503
|
+
withClineLock(() => {
|
|
504
|
+
const cleanSlot = (slot || "").trim();
|
|
505
|
+
if (!cleanSlot) return;
|
|
506
|
+
const live = readPoolFile();
|
|
507
|
+
if (!live.accounts.some((a) => a.slot === cleanSlot)) return;
|
|
508
|
+
const prev = live.usage?.[cleanSlot];
|
|
509
|
+
const served = typeof prev?.served === "number" && Number.isFinite(prev.served) && prev.served >= 0
|
|
510
|
+
? Math.floor(prev.served) + 1
|
|
511
|
+
: 1;
|
|
512
|
+
live.usage = {
|
|
513
|
+
...(live.usage ?? {}),
|
|
514
|
+
[cleanSlot]: { served, lastAt: Date.now(), lastModel: (modelId || "").trim() },
|
|
515
|
+
};
|
|
516
|
+
savePool(live);
|
|
517
|
+
});
|
|
464
518
|
}
|
|
465
519
|
|
|
466
520
|
/** The reset still in force for one slot+requested model, or null when free. */
|
|
@@ -477,30 +531,32 @@ export function activeLimitFor(pool: ClinePoolState, slot: string, model: string
|
|
|
477
531
|
* would revert a login added or removed while the request was in flight.
|
|
478
532
|
*/
|
|
479
533
|
export function recordLimitOnDisk(slot: string, model: string, resetAt: number): void {
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
534
|
+
withClineLock(() => {
|
|
535
|
+
if (!model) return;
|
|
536
|
+
const live = readPoolFile();
|
|
537
|
+
if (!live.accounts.some((a) => a.slot === slot)) return;
|
|
538
|
+
const known = live.limits?.[slot]?.[model];
|
|
539
|
+
if (typeof known === "number" && known >= resetAt) return;
|
|
540
|
+
const limits = live.limits ?? {};
|
|
541
|
+
limits[slot] = { ...(limits[slot] ?? {}), [model]: resetAt };
|
|
542
|
+
live.limits = limits;
|
|
543
|
+
savePool(live);
|
|
544
|
+
});
|
|
491
545
|
}
|
|
492
546
|
|
|
493
547
|
/** Drop one slot+model limit once that slot serves the model again. */
|
|
494
548
|
export function clearLimitOnDisk(slot: string, model: string): void {
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
549
|
+
withClineLock(() => {
|
|
550
|
+
if (!model) return;
|
|
551
|
+
const live = readPoolFile();
|
|
552
|
+
const limits = live.limits;
|
|
553
|
+
const forSlot = limits?.[slot];
|
|
554
|
+
if (!limits || !forSlot || typeof forSlot[model] !== "number") return;
|
|
555
|
+
delete forSlot[model];
|
|
556
|
+
if (Object.keys(forSlot).length === 0) delete limits[slot];
|
|
557
|
+
if (Object.keys(limits).length === 0) delete live.limits;
|
|
558
|
+
savePool(live);
|
|
559
|
+
});
|
|
504
560
|
}
|
|
505
561
|
|
|
506
562
|
/**
|
|
@@ -551,91 +607,85 @@ export function addAccount(
|
|
|
551
607
|
token: string,
|
|
552
608
|
extras?: { refreshToken?: string; expiresAt?: number; accountId?: string; email?: string },
|
|
553
609
|
): ClinePoolState {
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
}
|
|
566
|
-
if (existing) {
|
|
567
|
-
existing.token = cleanToken;
|
|
568
|
-
// A fresh login is proof the grant works again: a quarantine recorded
|
|
569
|
-
// against the credential it replaces must never outlive it.
|
|
570
|
-
delete existing.deadUntil;
|
|
571
|
-
if (extras?.refreshToken) existing.refreshToken = extras.refreshToken;
|
|
572
|
-
if (extras?.expiresAt !== undefined) {
|
|
573
|
-
if (Number.isFinite(extras.expiresAt) && extras.expiresAt > 0) existing.expiresAt = extras.expiresAt;
|
|
574
|
-
else delete existing.expiresAt;
|
|
575
|
-
}
|
|
576
|
-
if (extras?.accountId !== undefined) {
|
|
577
|
-
if (extras.accountId.trim()) existing.accountId = extras.accountId.trim();
|
|
578
|
-
else delete existing.accountId;
|
|
610
|
+
return withClineLock(() => {
|
|
611
|
+
const cleanSlot = (slot || "").trim();
|
|
612
|
+
if (!cleanSlot) throw new Error("Cline slot name cannot be empty");
|
|
613
|
+
if (cleanSlot.length > 64) throw new Error("Cline slot name is too long (max 64 characters)");
|
|
614
|
+
const cleanToken = (token || "").trim();
|
|
615
|
+
if (!cleanToken.startsWith("workos:") && !isWorkosJwt(cleanToken) && !cleanToken.startsWith("clp_")) throw new Error("Cline token must be a workos: login grant or a clp_ API key");
|
|
616
|
+
const pool = loadPool();
|
|
617
|
+
const existing = pool.accounts.find((a) => a.slot === cleanSlot);
|
|
618
|
+
const duplicate = findClineAccountSlot(pool, { token: cleanToken, accountId: extras?.accountId, email: extras?.email }, cleanSlot);
|
|
619
|
+
if (duplicate) {
|
|
620
|
+
const who = extras?.email?.trim() || existing?.email?.trim() || "this account";
|
|
621
|
+
throw new Error(`That Cline account (${who}) is already saved as [${duplicate}] — log in with a different account, or use /freeflow cline logout ${duplicate} first`);
|
|
579
622
|
}
|
|
580
|
-
if (
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
623
|
+
if (existing) {
|
|
624
|
+
existing.token = cleanToken;
|
|
625
|
+
delete existing.deadUntil;
|
|
626
|
+
if (extras?.refreshToken) existing.refreshToken = extras.refreshToken;
|
|
627
|
+
if (extras?.expiresAt !== undefined) {
|
|
628
|
+
if (Number.isFinite(extras.expiresAt) && extras.expiresAt > 0) existing.expiresAt = extras.expiresAt;
|
|
629
|
+
else delete existing.expiresAt;
|
|
630
|
+
}
|
|
631
|
+
if (extras?.accountId !== undefined) {
|
|
632
|
+
if (extras.accountId.trim()) existing.accountId = extras.accountId.trim();
|
|
633
|
+
else delete existing.accountId;
|
|
634
|
+
}
|
|
635
|
+
if (extras?.email !== undefined) {
|
|
636
|
+
if (extras.email.trim()) existing.email = extras.email.trim();
|
|
637
|
+
else delete existing.email;
|
|
638
|
+
}
|
|
639
|
+
} else {
|
|
640
|
+
pool.accounts.push({
|
|
641
|
+
slot: cleanSlot,
|
|
642
|
+
token: cleanToken,
|
|
643
|
+
addedAt: new Date().toISOString(),
|
|
644
|
+
...(extras?.refreshToken ? { refreshToken: extras.refreshToken } : {}),
|
|
645
|
+
...(extras?.expiresAt !== undefined && Number.isFinite(extras.expiresAt) && extras.expiresAt > 0
|
|
646
|
+
? { expiresAt: extras.expiresAt }
|
|
647
|
+
: {}),
|
|
648
|
+
...(extras?.accountId?.trim() ? { accountId: extras.accountId.trim() } : {}),
|
|
649
|
+
...(extras?.email?.trim() ? { email: extras.email.trim() } : {}),
|
|
650
|
+
});
|
|
608
651
|
}
|
|
609
|
-
if (!
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
652
|
+
if (!pool.activeSlot) pool.activeSlot = cleanSlot;
|
|
653
|
+
if (!savePool(pool)) {
|
|
654
|
+
let landed = false;
|
|
655
|
+
try {
|
|
656
|
+
landed = loadPool().accounts.some((a) => a.slot === cleanSlot && a.token === cleanToken);
|
|
657
|
+
} catch {
|
|
658
|
+
landed = false;
|
|
659
|
+
}
|
|
660
|
+
if (!landed) {
|
|
661
|
+
throw new Error(
|
|
662
|
+
`Cline login [${cleanSlot}] could not be saved to ${CLINE_POOL_FILE} — check that the directory is writable and log in again`,
|
|
663
|
+
);
|
|
664
|
+
}
|
|
613
665
|
}
|
|
614
|
-
|
|
615
|
-
|
|
666
|
+
return clonePool(pool);
|
|
667
|
+
});
|
|
616
668
|
}
|
|
617
669
|
|
|
618
670
|
/** Drop one login slot. Returns false when the slot was not saved. */
|
|
619
671
|
export function removeAccount(slot: string): boolean {
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
forgetAccountInBackup(removed);
|
|
640
|
-
return true;
|
|
672
|
+
return withClineLock(() => {
|
|
673
|
+
const cleanSlot = (slot || "").trim();
|
|
674
|
+
const pool = loadPool();
|
|
675
|
+
const idx = pool.accounts.findIndex((a) => a.slot === cleanSlot);
|
|
676
|
+
if (idx < 0) return false;
|
|
677
|
+
const [removed] = pool.accounts.splice(idx, 1);
|
|
678
|
+
if (pool.usage?.[cleanSlot] !== undefined) {
|
|
679
|
+
delete pool.usage[cleanSlot];
|
|
680
|
+
if (Object.keys(pool.usage).length === 0) delete pool.usage;
|
|
681
|
+
}
|
|
682
|
+
if (pool.limits?.[cleanSlot] !== undefined) {
|
|
683
|
+
delete pool.limits[cleanSlot];
|
|
684
|
+
if (Object.keys(pool.limits).length === 0) delete pool.limits;
|
|
685
|
+
}
|
|
686
|
+
if (pool.activeSlot === cleanSlot) pool.activeSlot = pool.accounts[0]?.slot;
|
|
687
|
+
savePool(pool);
|
|
688
|
+
forgetAccountInBackup(removed);
|
|
689
|
+
return true;
|
|
690
|
+
});
|
|
641
691
|
}
|
package/src/cline-roll.ts
CHANGED
|
@@ -323,6 +323,8 @@ export function isClineTokenStale(account: ClineAccount, skewMs = CLINE_REFRESH_
|
|
|
323
323
|
return Date.now() + skewMs >= account.expiresAt;
|
|
324
324
|
}
|
|
325
325
|
|
|
326
|
+
const refreshInFlight = new Map<string, Promise<boolean | null>>();
|
|
327
|
+
|
|
326
328
|
/**
|
|
327
329
|
* Refresh outcome for one slot: fresh tokens applied and persisted (`true`),
|
|
328
330
|
* dead grant with nothing to keep (`false`), or a transient fault where the
|
|
@@ -334,47 +336,73 @@ export async function refreshAccountInPlace(
|
|
|
334
336
|
): Promise<boolean | null> {
|
|
335
337
|
const current = account.refreshToken;
|
|
336
338
|
if (!current) return null;
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
339
|
+
if (refreshInFlight.has(account.slot)) {
|
|
340
|
+
const result = await refreshInFlight.get(account.slot)!;
|
|
341
|
+
const pool = loadPool();
|
|
342
|
+
const refreshed = pool.accounts.find((a) => a.slot === account.slot);
|
|
343
|
+
if (refreshed) {
|
|
344
|
+
account.token = refreshed.token;
|
|
345
|
+
if (refreshed.refreshToken) account.refreshToken = refreshed.refreshToken;
|
|
346
|
+
if (typeof refreshed.expiresAt === "number") {
|
|
347
|
+
account.expiresAt = refreshed.expiresAt;
|
|
348
|
+
} else {
|
|
349
|
+
delete account.expiresAt;
|
|
350
|
+
}
|
|
351
|
+
if (refreshed.accountId) account.accountId = refreshed.accountId;
|
|
352
|
+
if (refreshed.email) account.email = refreshed.email;
|
|
353
|
+
if (refreshed.deadUntil) account.deadUntil = refreshed.deadUntil;
|
|
354
|
+
}
|
|
355
|
+
return result;
|
|
344
356
|
}
|
|
345
|
-
const
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
357
|
+
const promise = (async (): Promise<boolean | null> => {
|
|
358
|
+
let fresh: ClineRefreshResult | null;
|
|
359
|
+
try {
|
|
360
|
+
fresh = await refreshImpl(current);
|
|
361
|
+
} catch (e) {
|
|
362
|
+
logWarn("cline slot refresh failed — keeping stale bearer", { slot: account.slot });
|
|
363
|
+
void e;
|
|
364
|
+
return null;
|
|
365
|
+
}
|
|
366
|
+
const freshToken = fresh !== null && typeof fresh.token === "string" ? fresh.token.trim() : "";
|
|
367
|
+
if (fresh === null || !freshToken || (!freshToken.startsWith("workos:") && !isWorkosJwt(freshToken) && !freshToken.startsWith("clp_"))) {
|
|
368
|
+
// Cline rejected the grant outright: park the slot on disk so the next
|
|
369
|
+
// turns skip it instead of re-paying a refresh that cannot succeed. The
|
|
370
|
+
// credential stays in the pool — a fresh login into the slot clears this —
|
|
371
|
+
// and the cooldown ends the pause on its own.
|
|
372
|
+
account.deadUntil = Date.now() + CLINE_DEAD_GRANT_COOLDOWN_MS;
|
|
373
|
+
logWarn("cline slot refresh rejected — quarantining slot", {
|
|
374
|
+
slot: account.slot,
|
|
375
|
+
forMs: CLINE_DEAD_GRANT_COOLDOWN_MS,
|
|
376
|
+
});
|
|
377
|
+
patchAccountOnDisk(account.slot, { deadUntil: account.deadUntil });
|
|
378
|
+
return false;
|
|
379
|
+
}
|
|
380
|
+
account.token = freshToken;
|
|
381
|
+
if (fresh.refreshToken) account.refreshToken = fresh.refreshToken;
|
|
382
|
+
if (typeof fresh.expiresAt === "number" && Number.isFinite(fresh.expiresAt) && fresh.expiresAt > 0) {
|
|
383
|
+
account.expiresAt = fresh.expiresAt;
|
|
384
|
+
} else {
|
|
385
|
+
delete account.expiresAt;
|
|
386
|
+
}
|
|
387
|
+
if (fresh.accountId?.trim()) account.accountId = fresh.accountId.trim();
|
|
388
|
+
if (fresh.email?.trim()) account.email = fresh.email.trim();
|
|
389
|
+
// Persist only the refreshed fields: the snapshot this request holds may
|
|
390
|
+
// predate a login added or removed in the host process.
|
|
391
|
+
patchAccountOnDisk(account.slot, {
|
|
392
|
+
token: account.token,
|
|
393
|
+
...(account.refreshToken ? { refreshToken: account.refreshToken } : {}),
|
|
394
|
+
expiresAt: account.expiresAt,
|
|
395
|
+
...(account.accountId ? { accountId: account.accountId } : {}),
|
|
396
|
+
...(account.email ? { email: account.email } : {}),
|
|
355
397
|
});
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
account.
|
|
363
|
-
} else {
|
|
364
|
-
delete account.expiresAt;
|
|
398
|
+
return true;
|
|
399
|
+
})();
|
|
400
|
+
refreshInFlight.set(account.slot, promise);
|
|
401
|
+
try {
|
|
402
|
+
return await promise;
|
|
403
|
+
} finally {
|
|
404
|
+
refreshInFlight.delete(account.slot);
|
|
365
405
|
}
|
|
366
|
-
if (fresh.accountId?.trim()) account.accountId = fresh.accountId.trim();
|
|
367
|
-
if (fresh.email?.trim()) account.email = fresh.email.trim();
|
|
368
|
-
// Persist only the refreshed fields: the snapshot this request holds may
|
|
369
|
-
// predate a login added or removed in the host process.
|
|
370
|
-
patchAccountOnDisk(account.slot, {
|
|
371
|
-
token: account.token,
|
|
372
|
-
...(account.refreshToken ? { refreshToken: account.refreshToken } : {}),
|
|
373
|
-
expiresAt: account.expiresAt,
|
|
374
|
-
...(account.accountId ? { accountId: account.accountId } : {}),
|
|
375
|
-
...(account.email ? { email: account.email } : {}),
|
|
376
|
-
});
|
|
377
|
-
return true;
|
|
378
406
|
}
|
|
379
407
|
|
|
380
408
|
/**
|
|
@@ -532,6 +560,11 @@ export async function rollChat(opts: ClineRollOpts): Promise<ClineRollResult> {
|
|
|
532
560
|
refreshedThisSlot = true;
|
|
533
561
|
res = await attempt(account.token);
|
|
534
562
|
if (!res) continue;
|
|
563
|
+
if (res.status === 200 && res.headers.get("content-type")?.includes("text/event-stream")) {
|
|
564
|
+
const peek = await peekStreamForInStreamError(res);
|
|
565
|
+
res = peek.res;
|
|
566
|
+
if (peek.inStreamError) inStreamLimitText = peek.errorText;
|
|
567
|
+
}
|
|
535
568
|
kind = mapClineError(res.status);
|
|
536
569
|
if (kind === "ok" || kind === "client") return await succeed(res, kind);
|
|
537
570
|
} else if (outcome === false) {
|
package/src/commands-relay.ts
CHANGED
|
@@ -201,7 +201,7 @@ export function showList(ctx: ExtensionContext, getRelayState: () => RelayState)
|
|
|
201
201
|
export async function removeRelayMenu(ctx: ExtensionContext, act: Pick<RelayActionContext, "applyRelayState" | "persist" | "getRelayState">): Promise<void> {
|
|
202
202
|
const relayState = act.getRelayState();
|
|
203
203
|
const removable = relayState.relays.filter(
|
|
204
|
-
(r) => r.url !== relayState.url,
|
|
204
|
+
(r) => relayState.relays.length === 1 || r.url !== relayState.url,
|
|
205
205
|
);
|
|
206
206
|
if (!removable.length) {
|
|
207
207
|
ctx.ui.notify(
|
|
@@ -718,7 +718,7 @@ export async function handleRelaySubcommand(
|
|
|
718
718
|
ctx.ui.notify(`Relay '${target}' not in saved list`, "warning");
|
|
719
719
|
return true;
|
|
720
720
|
}
|
|
721
|
-
if (matched.url === relayState.url) {
|
|
721
|
+
if (matched.url === relayState.url && relayState.relays.length > 1) {
|
|
722
722
|
ctx.ui.notify(
|
|
723
723
|
"Cannot remove the active relay — switch to another relay first",
|
|
724
724
|
"warning",
|
package/src/commands-startup.ts
CHANGED
|
@@ -237,7 +237,10 @@ export function uninstallStartupHook(opts?: {
|
|
|
237
237
|
return { ok: true, changed: false, undo: plan.command, detail: `already removed — no unit file at ${plan.filePath}` };
|
|
238
238
|
}
|
|
239
239
|
try {
|
|
240
|
-
|
|
240
|
+
startupHookRunner(`systemctl --user disable ${STARTUP_SERVICE_NAME}`);
|
|
241
|
+
} catch { }
|
|
242
|
+
try {
|
|
243
|
+
startupHookRunner("systemctl --user daemon-reload");
|
|
241
244
|
} catch { }
|
|
242
245
|
fs.rmSync(plan.filePath, { force: true });
|
|
243
246
|
return { ok: true, changed: true, undo: plan.command, detail: `removed ${plan.filePath} (re-install: ${plan.command})` };
|
package/src/commands.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import path from "node:path";
|
|
8
|
-
import { getClientPort } from "./client.ts";
|
|
8
|
+
import { ensureDaemon as ensureClientDaemon, getClientPort, stopHeartbeat } from "./client.ts";
|
|
9
9
|
import { refreshCatalog, setAliveCatalog } from "./catalog.ts";
|
|
10
10
|
import { DATA_DIR_ENV, DEBUG_STATE_FILE, HOST, LOG_FILE, PORT, RELAY_STATE_FILE, ZEN_TRANSPORT_ENV } from "./config.ts";
|
|
11
11
|
import { getCachedUpdate, isLinkedInstall } from "./update-checker.ts";
|
|
@@ -144,7 +144,7 @@ export {
|
|
|
144
144
|
*/
|
|
145
145
|
export function updateStatusBar(ui?: ExtensionUIContext): void {
|
|
146
146
|
if (!ui) return;
|
|
147
|
-
ui.setStatus("freeflow", formatRelayStatusLabel(getActiveRelayState()) ?? undefined);
|
|
147
|
+
ui.setStatus?.("freeflow", formatRelayStatusLabel(getActiveRelayState()) ?? undefined);
|
|
148
148
|
}
|
|
149
149
|
|
|
150
150
|
let deployProbeRetries = 5;
|
|
@@ -253,7 +253,8 @@ export function validateDeployProjectName(
|
|
|
253
253
|
*/
|
|
254
254
|
let updateStepRunner = spawnWithProgress;
|
|
255
255
|
let attachedPortResolver = (): number => getClientPort() || PORT;
|
|
256
|
-
let attachedPortKiller
|
|
256
|
+
let attachedPortKiller: (port: number) => Promise<unknown> = killPortHolder;
|
|
257
|
+
let daemonEnsurer: () => Promise<unknown> = ensureClientDaemon;
|
|
257
258
|
|
|
258
259
|
/** Test seam for the /freeflow update restart path (null restores the real deps). */
|
|
259
260
|
export function _setUpdateRestartDepsForTest(
|
|
@@ -261,11 +262,13 @@ export function _setUpdateRestartDepsForTest(
|
|
|
261
262
|
runUpdateStep?: (cmd: string, args: string[], ctx: ExtensionContext) => Promise<number>;
|
|
262
263
|
resolveAttachedPort?: () => number;
|
|
263
264
|
killAttachedPort?: (port: number) => Promise<unknown>;
|
|
265
|
+
ensureDaemon?: () => Promise<unknown>;
|
|
264
266
|
} | null,
|
|
265
267
|
): void {
|
|
266
268
|
updateStepRunner = opts?.runUpdateStep ?? spawnWithProgress;
|
|
267
269
|
attachedPortResolver = opts?.resolveAttachedPort ?? ((): number => getClientPort() || PORT);
|
|
268
|
-
attachedPortKiller = opts?.killAttachedPort ??
|
|
270
|
+
attachedPortKiller = opts?.killAttachedPort ?? killPortHolder;
|
|
271
|
+
daemonEnsurer = opts?.ensureDaemon ?? ensureClientDaemon;
|
|
269
272
|
}
|
|
270
273
|
|
|
271
274
|
export function createCommandSpec(
|
|
@@ -430,7 +433,7 @@ export function createCommandSpec(
|
|
|
430
433
|
}
|
|
431
434
|
}
|
|
432
435
|
|
|
433
|
-
|
|
436
|
+
ctx.ui.setStatus?.("freeflow", `deploying ${platform} relay…`);
|
|
434
437
|
try {
|
|
435
438
|
const deployer =
|
|
436
439
|
platform === "cloudflare"
|
|
@@ -639,6 +642,22 @@ export function createCommandSpec(
|
|
|
639
642
|
} catch {
|
|
640
643
|
ctx.ui.notify("Daemon not running or not reachable", "warning");
|
|
641
644
|
}
|
|
645
|
+
try { await stopHeartbeat(); } catch { }
|
|
646
|
+
} else if (sub === "restart") {
|
|
647
|
+
const port = getClientPort() || PORT;
|
|
648
|
+
try {
|
|
649
|
+
await fetch(`http://${HOST}:${port}/_shutdown`, {
|
|
650
|
+
method: "POST",
|
|
651
|
+
headers: tokenAuthHeaders(),
|
|
652
|
+
signal: AbortSignal.timeout(1500),
|
|
653
|
+
});
|
|
654
|
+
} catch { }
|
|
655
|
+
try {
|
|
656
|
+
await ensureClientDaemon();
|
|
657
|
+
ctx.ui.notify("Proxy daemon restarted", "info");
|
|
658
|
+
} catch (e) {
|
|
659
|
+
ctx.ui.notify(`Restart failed: ${(e as Error).message}`, "error");
|
|
660
|
+
}
|
|
642
661
|
} else if (sub === "autoupdate" || (sub === "update" && rest.trim().toLowerCase().startsWith("auto"))) {
|
|
643
662
|
const arg = sub === "autoupdate"
|
|
644
663
|
? rest.trim().toLowerCase()
|
|
@@ -718,6 +737,7 @@ export function createCommandSpec(
|
|
|
718
737
|
// Restart the daemon that is actually attached — a walked port must
|
|
719
738
|
// not leave the old build serving after a successful update.
|
|
720
739
|
try { await attachedPortKiller(attachedPortResolver()); } catch { }
|
|
740
|
+
try { await daemonEnsurer(); } catch { }
|
|
721
741
|
ctx.ui.notify(`Updated to ${latest} and restarted proxy daemon`, "info");
|
|
722
742
|
} else {
|
|
723
743
|
ctx.ui.notify(
|
|
@@ -792,7 +812,9 @@ export function createCommandSpec(
|
|
|
792
812
|
let count = 25;
|
|
793
813
|
const rawTokens = rawRest ? rawRest.split(/\s+/) : [];
|
|
794
814
|
const isFollow = rawTokens.includes("--follow") || rawTokens.includes("-f");
|
|
795
|
-
|
|
815
|
+
if (!isFollow) {
|
|
816
|
+
stopLogsFollow();
|
|
817
|
+
}
|
|
796
818
|
if (sub === "trace" && rawRest) {
|
|
797
819
|
filterReqId = rawTokens.filter((t) => t !== "--follow" && t !== "-f")[0] || null;
|
|
798
820
|
} else if (rawRest) {
|