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/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
- fs.writeFileSync(CLINE_POOL_BACKUP_FILE, raw, { encoding: "utf8", mode: 0o600 });
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
- fs.writeFileSync(CLINE_POOL_BACKUP_FILE, JSON.stringify(doc, null, 2), { encoding: "utf8", mode: 0o600 });
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
- const live = readPoolFile();
428
- const target = live.accounts.find((a) => a.slot === slot);
429
- if (!target) return;
430
- Object.assign(target, patch);
431
- savePool(live);
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
- const live = readPoolFile();
437
- if (!live.accounts.some((a) => a.slot === slot)) return;
438
- live.activeSlot = slot;
439
- savePool(live);
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
- const cleanSlot = (slot || "").trim();
452
- if (!cleanSlot) return;
453
- const live = readPoolFile();
454
- if (!live.accounts.some((a) => a.slot === cleanSlot)) return;
455
- const prev = live.usage?.[cleanSlot];
456
- const served = typeof prev?.served === "number" && Number.isFinite(prev.served) && prev.served >= 0
457
- ? Math.floor(prev.served) + 1
458
- : 1;
459
- live.usage = {
460
- ...(live.usage ?? {}),
461
- [cleanSlot]: { served, lastAt: Date.now(), lastModel: (modelId || "").trim() },
462
- };
463
- savePool(live);
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
- if (!model) return;
481
- const live = readPoolFile();
482
- if (!live.accounts.some((a) => a.slot === slot)) return;
483
- const known = live.limits?.[slot]?.[model];
484
- // Keep the later of the two: a fresh report must not shorten a window that is
485
- // already known, and repeating the same reset writes nothing.
486
- if (typeof known === "number" && known >= resetAt) return;
487
- const limits = live.limits ?? {};
488
- limits[slot] = { ...(limits[slot] ?? {}), [model]: resetAt };
489
- live.limits = limits;
490
- savePool(live);
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
- if (!model) return;
496
- const live = readPoolFile();
497
- const limits = live.limits;
498
- const forSlot = limits?.[slot];
499
- if (!limits || !forSlot || typeof forSlot[model] !== "number") return;
500
- delete forSlot[model];
501
- if (Object.keys(forSlot).length === 0) delete limits[slot];
502
- if (Object.keys(limits).length === 0) delete live.limits;
503
- savePool(live);
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
- const cleanSlot = (slot || "").trim();
555
- if (!cleanSlot) throw new Error("Cline slot name cannot be empty");
556
- if (cleanSlot.length > 64) throw new Error("Cline slot name is too long (max 64 characters)");
557
- const cleanToken = (token || "").trim();
558
- 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");
559
- const pool = loadPool();
560
- const existing = pool.accounts.find((a) => a.slot === cleanSlot);
561
- const duplicate = findClineAccountSlot(pool, { token: cleanToken, accountId: extras?.accountId, email: extras?.email }, cleanSlot);
562
- if (duplicate) {
563
- const who = extras?.email?.trim() || existing?.email?.trim() || "this account";
564
- 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`);
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 (extras?.email !== undefined) {
581
- if (extras.email.trim()) existing.email = extras.email.trim();
582
- else delete existing.email;
583
- }
584
- } else {
585
- pool.accounts.push({
586
- slot: cleanSlot,
587
- token: cleanToken,
588
- addedAt: new Date().toISOString(),
589
- ...(extras?.refreshToken ? { refreshToken: extras.refreshToken } : {}),
590
- ...(extras?.expiresAt !== undefined && Number.isFinite(extras.expiresAt) && extras.expiresAt > 0
591
- ? { expiresAt: extras.expiresAt }
592
- : {}),
593
- ...(extras?.accountId?.trim() ? { accountId: extras.accountId.trim() } : {}),
594
- ...(extras?.email?.trim() ? { email: extras.email.trim() } : {}),
595
- });
596
- }
597
- if (!pool.activeSlot) pool.activeSlot = cleanSlot;
598
- // A save that never reached the disk must not report success: the caller
599
- // prints "ready to use" on the strength of this return, and the login would
600
- // vanish on the next read. The write helper already logged the cause.
601
- if (!savePool(pool)) {
602
- // Re-read the way the relay deploy path does before blaming the write.
603
- let landed = false;
604
- try {
605
- landed = loadPool().accounts.some((a) => a.slot === cleanSlot && a.token === cleanToken);
606
- } catch {
607
- landed = false;
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 (!landed) {
610
- throw new Error(
611
- `Cline login [${cleanSlot}] could not be saved to ${CLINE_POOL_FILE} — check that the directory is writable and log in again`,
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
- return clonePool(pool);
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
- const cleanSlot = (slot || "").trim();
621
- const pool = loadPool();
622
- const idx = pool.accounts.findIndex((a) => a.slot === cleanSlot);
623
- if (idx < 0) return false;
624
- const [removed] = pool.accounts.splice(idx, 1);
625
- if (pool.usage?.[cleanSlot] !== undefined) {
626
- delete pool.usage[cleanSlot];
627
- if (Object.keys(pool.usage).length === 0) delete pool.usage;
628
- }
629
- // A cap belongs to the login that hit it: keeping it would outlive the slot
630
- // it names and key a later login of the same name into a stale window.
631
- if (pool.limits?.[cleanSlot] !== undefined) {
632
- delete pool.limits[cleanSlot];
633
- if (Object.keys(pool.limits).length === 0) delete pool.limits;
634
- }
635
- if (pool.activeSlot === cleanSlot) pool.activeSlot = pool.accounts[0]?.slot;
636
- savePool(pool);
637
- // A logout is explicit: the removed grant must not survive in the recovery
638
- // copy, or losing the main file would sign that account back in.
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
- let fresh: ClineRefreshResult | null;
338
- try {
339
- fresh = await refreshImpl(current);
340
- } catch (e) {
341
- logWarn("cline slot refresh failed — keeping stale bearer", { slot: account.slot });
342
- void e;
343
- return null;
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 freshToken = fresh !== null && typeof fresh.token === "string" ? fresh.token.trim() : "";
346
- if (fresh === null || !freshToken || (!freshToken.startsWith("workos:") && !isWorkosJwt(freshToken) && !freshToken.startsWith("clp_"))) {
347
- // Cline rejected the grant outright: park the slot on disk so the next
348
- // turns skip it instead of re-paying a refresh that cannot succeed. The
349
- // credential stays in the pool — a fresh login into the slot clears this —
350
- // and the cooldown ends the pause on its own.
351
- account.deadUntil = Date.now() + CLINE_DEAD_GRANT_COOLDOWN_MS;
352
- logWarn("cline slot refresh rejected — quarantining slot", {
353
- slot: account.slot,
354
- forMs: CLINE_DEAD_GRANT_COOLDOWN_MS,
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
- patchAccountOnDisk(account.slot, { deadUntil: account.deadUntil });
357
- return false;
358
- }
359
- account.token = freshToken;
360
- if (fresh.refreshToken) account.refreshToken = fresh.refreshToken;
361
- if (typeof fresh.expiresAt === "number" && Number.isFinite(fresh.expiresAt) && fresh.expiresAt > 0) {
362
- account.expiresAt = fresh.expiresAt;
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) {
@@ -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",
@@ -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
- execSync(`systemctl --user disable ${STARTUP_SERVICE_NAME}`, { timeout: 10_000, stdio: "ignore" });
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 = (port: number): Promise<unknown> => killPortHolder(port);
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 ?? ((port: number): Promise<unknown> => killPortHolder(port));
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
- ctx.ui.setStatus("freeflow", `deploying ${platform} relay…`);
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) {