bitfab-cli 0.2.186 → 0.2.188

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 (2) hide show
  1. package/dist/index.js +374 -58
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -8837,6 +8837,12 @@ var DaemonUnavailableError = class extends Error {
8837
8837
  this.name = "DaemonUnavailableError";
8838
8838
  }
8839
8839
  };
8840
+ var DaemonOpenTimeoutError = class extends Error {
8841
+ constructor(message = "daemon did not respond to open") {
8842
+ super(message);
8843
+ this.name = "DaemonOpenTimeoutError";
8844
+ }
8845
+ };
8840
8846
  var StudioNavigationError = class extends Error {
8841
8847
  reason;
8842
8848
  blockedReason;
@@ -9566,6 +9572,40 @@ function updateActiveStudioSession(updates) {
9566
9572
  writeStateFile(sessionFilePath(), updated);
9567
9573
  return updated;
9568
9574
  }
9575
+ function clearActiveStudioSession(sessionId, opts = {}) {
9576
+ try {
9577
+ const current = readStateFile(sessionFilePath());
9578
+ if (!current) {
9579
+ return clearLegacySession(sessionId, opts);
9580
+ }
9581
+ if (current.sessionId !== sessionId) {
9582
+ return false;
9583
+ }
9584
+ if (!opts.force && current.pollerPid != null && current.pollerPid !== process.pid && isProcessAlive(current.pollerPid)) {
9585
+ return false;
9586
+ }
9587
+ fs7.unlinkSync(sessionFilePath());
9588
+ return true;
9589
+ } catch {
9590
+ return false;
9591
+ }
9592
+ }
9593
+ function clearLegacySession(sessionId, opts = {}) {
9594
+ try {
9595
+ const raw = fs7.readFileSync(legacyFilePath(), "utf-8");
9596
+ const parsed = JSON.parse(raw);
9597
+ if (parsed.sessionId !== sessionId) {
9598
+ return false;
9599
+ }
9600
+ if (!opts.force && typeof parsed.pid === "number" && parsed.pid !== process.pid && isProcessAlive(parsed.pid)) {
9601
+ return false;
9602
+ }
9603
+ fs7.unlinkSync(legacyFilePath());
9604
+ return true;
9605
+ } catch {
9606
+ return false;
9607
+ }
9608
+ }
9569
9609
  function isProcessAlive(pid) {
9570
9610
  try {
9571
9611
  process.kill(pid, 0);
@@ -9948,6 +9988,9 @@ function closeStudioWindowsBySession(sessionId) {
9948
9988
  }
9949
9989
  }
9950
9990
  }
9991
+ function osDefaultResult(pid) {
9992
+ return pid !== null ? { pid, launched: true, method: "os-default" } : { pid: null, launched: false, method: "none", reason: "spawn-failed" };
9993
+ }
9951
9994
  function openChromelessWindow(url2) {
9952
9995
  if (process.env.VITEST || process.env.NODE_ENV === "test") {
9953
9996
  throw new Error("openChromelessWindow called in a test environment without being mocked");
@@ -9958,12 +10001,16 @@ function openChromelessWindow(url2) {
9958
10001
  }
9959
10002
  if (recentOpens.length >= WINDOW_OPEN_RATE_LIMIT) {
9960
10003
  console.error(`[browser] rate limit: ${recentOpens.length} windows opened in the last ${WINDOW_OPEN_RATE_WINDOW_MS / 1e3}s, refusing to open another`);
9961
- return null;
10004
+ return {
10005
+ pid: null,
10006
+ launched: false,
10007
+ method: "none",
10008
+ reason: "rate-limited"
10009
+ };
9962
10010
  }
9963
10011
  recentOpens.push(now);
9964
10012
  if (shouldDisableChromelessWindows()) {
9965
- openOsDefault(url2);
9966
- return null;
10013
+ return osDefaultResult(openOsDefault(url2));
9967
10014
  }
9968
10015
  const browsers = getChromiumBrowsers();
9969
10016
  const defaultId = getDefaultBrowserId();
@@ -9974,29 +10021,31 @@ function openChromelessWindow(url2) {
9974
10021
  if (binary) {
9975
10022
  const pid = openChromiumApp(binary, url2, sizeArgs);
9976
10023
  if (pid !== null) {
9977
- return pid;
10024
+ return { pid, launched: true, method: "app" };
9978
10025
  }
9979
- openOsDefault(url2);
9980
- return null;
10026
+ return osDefaultResult(openOsDefault(url2));
9981
10027
  }
9982
10028
  }
9983
10029
  if (defaultId !== null && defaultChromium === null) {
9984
- openOsDefault(url2);
9985
- return null;
10030
+ return osDefaultResult(openOsDefault(url2));
9986
10031
  }
9987
10032
  for (const browser of browsers) {
9988
10033
  const binary = findExistingBinary(browser.binaryPaths);
9989
10034
  if (binary) {
9990
10035
  const pid = openChromiumApp(binary, url2, sizeArgs);
9991
10036
  if (pid !== null) {
9992
- return pid;
10037
+ return { pid, launched: true, method: "app" };
9993
10038
  }
9994
- openOsDefault(url2);
9995
- return null;
10039
+ return osDefaultResult(openOsDefault(url2));
9996
10040
  }
9997
10041
  }
9998
- openOsDefault(url2);
9999
- return null;
10042
+ return osDefaultResult(openOsDefault(url2));
10043
+ }
10044
+
10045
+ // ../bitfab-plugin-lib/dist/output.js
10046
+ function emitJson(event) {
10047
+ process.stdout.write(`${JSON.stringify(event)}
10048
+ `);
10000
10049
  }
10001
10050
 
10002
10051
  // ../bitfab-plugin-lib/dist/parseArgs.js
@@ -10112,14 +10161,19 @@ async function createStudioSession({ serviceUrl, apiKey, sessionId, initialPath
10112
10161
  sessionId,
10113
10162
  extraHeaders: clientHeaders
10114
10163
  });
10115
- const windowPid = openChromelessWindow(target.url);
10164
+ const open = openChromelessWindow(target.url);
10116
10165
  writeActiveStudioSession({
10117
10166
  sessionId,
10118
10167
  serviceUrl,
10119
- windowPid,
10168
+ windowPid: open.pid,
10120
10169
  currentPath: target.path
10121
10170
  });
10122
- return { sessionId, serviceUrl };
10171
+ return {
10172
+ sessionId,
10173
+ serviceUrl,
10174
+ launched: open.launched,
10175
+ launchReason: open.reason
10176
+ };
10123
10177
  }
10124
10178
 
10125
10179
  // ../bitfab-plugin-lib/dist/daemon/client.js
@@ -10163,6 +10217,8 @@ var BROWSER_HEALTH_INTERVAL_MS = 10 * 1e3;
10163
10217
  var ENSURE_POLL_INTERVAL_MS = 200;
10164
10218
  var ENSURE_TIMEOUT_MS = 5e3;
10165
10219
  var PING_TIMEOUT_MS = 2e3;
10220
+ var OPEN_TIMEOUT_MS = 1e4;
10221
+ var COMMAND_TIMEOUT_MS = 3e4;
10166
10222
  function serialize(msg) {
10167
10223
  return `${JSON.stringify(msg)}
10168
10224
  `;
@@ -10336,7 +10392,7 @@ async function waitForLockHolderDaemon(socketPath, timeoutMs, localBuild = local
10336
10392
  const deadline = Date.now() + timeoutMs;
10337
10393
  while (Date.now() < deadline) {
10338
10394
  const ping = await pingSocket(socketPath);
10339
- if (ping.alive && (localBuild === "unknown" || !ping.build || ping.build === localBuild)) {
10395
+ if (ping.alive && buildMatches(localBuild, ping.build)) {
10340
10396
  return;
10341
10397
  }
10342
10398
  await new Promise((r) => setTimeout(r, ENSURE_POLL_INTERVAL_MS));
@@ -10347,6 +10403,12 @@ async function waitForLockHolderDaemon(socketPath, timeoutMs, localBuild = local
10347
10403
  }
10348
10404
  throw new Error(`daemon did not become ready within ${timeoutMs}ms`);
10349
10405
  }
10406
+ function buildMatches(localBuild, remoteBuild) {
10407
+ if (localBuild === "unknown") {
10408
+ return true;
10409
+ }
10410
+ return remoteBuild === localBuild;
10411
+ }
10350
10412
  function localBuildFingerprint() {
10351
10413
  try {
10352
10414
  const thisDir = path9.dirname(fileURLToPath3(import.meta.url));
@@ -10498,11 +10560,10 @@ async function ensureDaemon(paths = SINGLETON_DAEMON_PATHS) {
10498
10560
  const ping = await pingSocket(socketPath);
10499
10561
  if (ping.alive) {
10500
10562
  const localBuild = localBuildFingerprint();
10501
- if (ping.build && ping.build !== localBuild && localBuild !== "unknown") {
10502
- console.error(`[daemon] build mismatch (running: ${ping.build}, local: ${localBuild}), restarting`);
10503
- } else {
10563
+ if (buildMatches(localBuild, ping.build)) {
10504
10564
  return;
10505
10565
  }
10566
+ console.error(`[daemon] build mismatch (running: ${ping.build ?? "unreported"}, local: ${localBuild}), restarting`);
10506
10567
  }
10507
10568
  }
10508
10569
  if (!acquireLock(socketPath)) {
@@ -10527,9 +10588,19 @@ var DaemonClient = class {
10527
10588
  disconnectHandler = null;
10528
10589
  paths;
10529
10590
  socketPath;
10530
- constructor(paths = SINGLETON_DAEMON_PATHS) {
10591
+ openTimeoutMs;
10592
+ commandTimeoutMs;
10593
+ // The single in-flight wedged-daemon reap, shared by every timeout path.
10594
+ // Sharing it is load-bearing: `open`'s retry must AWAIT the same reap the
10595
+ // command timeout fire-and-forgot, or a trailing reap's file cleanup could
10596
+ // finish after the respawned daemon bound its socket and wrote its pid
10597
+ // file, unlinking the new daemon's files and orphaning it.
10598
+ reapInFlight = null;
10599
+ constructor(paths = SINGLETON_DAEMON_PATHS, timeouts = {}) {
10531
10600
  this.paths = paths;
10532
10601
  this.socketPath = paths.socketPath;
10602
+ this.openTimeoutMs = timeouts.openTimeoutMs ?? OPEN_TIMEOUT_MS;
10603
+ this.commandTimeoutMs = timeouts.commandTimeoutMs ?? COMMAND_TIMEOUT_MS;
10533
10604
  }
10534
10605
  async connect() {
10535
10606
  await ensureDaemon(this.paths);
@@ -10579,28 +10650,97 @@ var DaemonClient = class {
10579
10650
  idx = this.buffer.indexOf("\n");
10580
10651
  }
10581
10652
  }
10582
- sendCommand(cmd) {
10653
+ // Every command runs under a timeout so a wedged daemon can never hang a
10654
+ // caller indefinitely: a never-resolving promise is not a rejection, so it
10655
+ // slips straight past callers' .catch() guards. `open` passes its own tighter
10656
+ // timeout + typed error to drive the reap-and-retry path; everything else
10657
+ // gets the generous default ceiling.
10658
+ sendCommand(cmd, opts = {}) {
10583
10659
  if (!this.socket) {
10584
10660
  return Promise.reject(new Error("not connected"));
10585
10661
  }
10662
+ const timeoutMs = opts.timeoutMs ?? this.commandTimeoutMs;
10663
+ const cmdName = cmd.cmd ?? "command";
10586
10664
  return new Promise((resolve2, reject) => {
10587
- this.responseQueue.push(resolve2);
10665
+ let settled = false;
10666
+ const timer = setTimeout(() => {
10667
+ if (settled) {
10668
+ return;
10669
+ }
10670
+ settled = true;
10671
+ const pending = this.responseQueue;
10672
+ this.responseQueue = [];
10673
+ for (const other of pending) {
10674
+ if (other !== resolver) {
10675
+ other({
10676
+ ok: false,
10677
+ error: `daemon connection reset after ${cmdName} timed out`
10678
+ });
10679
+ }
10680
+ }
10681
+ this.destroy();
10682
+ void this.reapWedgedDaemon();
10683
+ reject(opts.onTimeout?.() ?? new Error(`daemon did not respond to ${cmdName} within ${timeoutMs}ms`));
10684
+ }, timeoutMs);
10685
+ const resolver = (msg) => {
10686
+ if (settled) {
10687
+ return;
10688
+ }
10689
+ settled = true;
10690
+ clearTimeout(timer);
10691
+ resolve2(msg);
10692
+ };
10693
+ this.responseQueue.push(resolver);
10588
10694
  try {
10589
10695
  this.socket.write(serialize(cmd));
10590
10696
  } catch (err) {
10591
- this.responseQueue.pop();
10697
+ settled = true;
10698
+ clearTimeout(timer);
10699
+ const idx = this.responseQueue.indexOf(resolver);
10700
+ if (idx !== -1) {
10701
+ this.responseQueue.splice(idx, 1);
10702
+ }
10592
10703
  reject(err);
10593
10704
  }
10594
10705
  });
10595
10706
  }
10596
10707
  async open(opts) {
10708
+ try {
10709
+ return await this.openOnce(opts);
10710
+ } catch (err) {
10711
+ if (!(err instanceof DaemonOpenTimeoutError)) {
10712
+ throw err;
10713
+ }
10714
+ this.destroy();
10715
+ await this.reapWedgedDaemon();
10716
+ await this.connect();
10717
+ return await this.openOnce(opts);
10718
+ }
10719
+ }
10720
+ async openOnce(opts) {
10597
10721
  const cmd = { cmd: "open", ...opts };
10598
- const res = await this.sendCommand(cmd);
10722
+ const res = await this.sendCommand(cmd, {
10723
+ timeoutMs: this.openTimeoutMs,
10724
+ onTimeout: () => new DaemonOpenTimeoutError()
10725
+ });
10599
10726
  if (!res.ok) {
10600
10727
  throw new Error(res.error);
10601
10728
  }
10602
10729
  return res;
10603
10730
  }
10731
+ // Start (or join) the wedged-daemon reap. All timeout paths funnel through
10732
+ // this so at most one reap runs at a time and `open`'s retry can await the
10733
+ // in-flight one instead of racing it - a reap that finished AFTER the retry
10734
+ // respawned would unlink the new daemon's socket/pid files and orphan it.
10735
+ reapWedgedDaemon() {
10736
+ if (!this.reapInFlight) {
10737
+ this.reapInFlight = reapAllDaemons(this.paths).catch(() => {
10738
+ }).finally(() => {
10739
+ this.reapInFlight = null;
10740
+ });
10741
+ }
10742
+ return this.reapInFlight;
10743
+ }
10604
10744
  async navigate(key, navPath) {
10605
10745
  const res = await this.sendCommand({ cmd: "navigate", key, path: navPath });
10606
10746
  if (!res.ok) {
@@ -10780,11 +10920,11 @@ var DirectChannel = class {
10780
10920
  path: initialPath,
10781
10921
  sessionId: sessionId2
10782
10922
  });
10783
- const windowPid = openChromelessWindow(url2);
10923
+ const open = openChromelessWindow(url2);
10784
10924
  writeActiveStudioSession({
10785
10925
  sessionId: sessionId2,
10786
10926
  serviceUrl: this.serviceUrl,
10787
- windowPid,
10927
+ windowPid: open.pid,
10788
10928
  currentPath: initialPath,
10789
10929
  // A keyless login window is pre-auth: writeActiveStudioSession defaults
10790
10930
  // authenticated to true, but this window has no token yet. Record it as
@@ -10793,7 +10933,12 @@ var DirectChannel = class {
10793
10933
  // (openStudioTo) is what later excludes it from reuse.
10794
10934
  authenticated: false
10795
10935
  });
10796
- return { sessionId: sessionId2, opened: true };
10936
+ return {
10937
+ sessionId: sessionId2,
10938
+ opened: true,
10939
+ launched: open.launched,
10940
+ launchReason: open.reason
10941
+ };
10797
10942
  }
10798
10943
  const existing = readActiveStudioSession();
10799
10944
  if (existing && existing.windowState !== "closed") {
@@ -10817,7 +10962,12 @@ var DirectChannel = class {
10817
10962
  initialPath: path21,
10818
10963
  clientHeaders: opts?.clientHeaders
10819
10964
  });
10820
- return { sessionId: session.sessionId, opened: true };
10965
+ return {
10966
+ sessionId: session.sessionId,
10967
+ opened: true,
10968
+ launched: session.launched,
10969
+ launchReason: session.launchReason
10970
+ };
10821
10971
  }
10822
10972
  async close(sessionId, message) {
10823
10973
  await closeStudio({ serviceUrl: this.serviceUrl, apiKey: this.apiKey, sessionId }, message);
@@ -10899,7 +11049,12 @@ var DaemonChannel = class _DaemonChannel {
10899
11049
  }
10900
11050
  return { sessionId: openRes.sessionId, opened: false };
10901
11051
  }
10902
- return { sessionId: openRes.sessionId, opened: true };
11052
+ return {
11053
+ sessionId: openRes.sessionId,
11054
+ opened: true,
11055
+ launched: openRes.launched,
11056
+ launchReason: openRes.launchReason
11057
+ };
10903
11058
  }
10904
11059
  async close(sessionId, message) {
10905
11060
  await Promise.all([
@@ -11003,6 +11158,48 @@ async function resolveChannel(apiKey, serviceUrl) {
11003
11158
  return new DirectChannel(apiKey, serviceUrl);
11004
11159
  }
11005
11160
 
11161
+ // ../bitfab-plugin-lib/dist/studioTeardown.js
11162
+ function killStudioWindow(sessionId) {
11163
+ const record2 = readActiveStudioSession();
11164
+ if (record2?.sessionId !== sessionId || record2.windowPid == null) {
11165
+ return;
11166
+ }
11167
+ try {
11168
+ process.kill(record2.windowPid, "SIGTERM");
11169
+ } catch {
11170
+ }
11171
+ }
11172
+ function killPollerProcess(sessionId) {
11173
+ const record2 = readActiveStudioSession();
11174
+ if (record2?.sessionId !== sessionId || record2.pollerPid == null || record2.pollerPid === process.pid) {
11175
+ return;
11176
+ }
11177
+ try {
11178
+ process.kill(record2.pollerPid, "SIGTERM");
11179
+ } catch {
11180
+ }
11181
+ }
11182
+
11183
+ // ../bitfab-plugin-lib/dist/commands/clearStudioSession.js
11184
+ async function clearStudioSessionById(sessionId, record2) {
11185
+ killStudioWindow(sessionId);
11186
+ const alreadyClosed = record2?.sessionId === sessionId && record2.windowState === "closed";
11187
+ if (!alreadyClosed) {
11188
+ closeStudioWindowsBySession(sessionId);
11189
+ }
11190
+ killPollerProcess(sessionId);
11191
+ try {
11192
+ const channel = await resolveChannel("", record2?.serviceUrl ?? "");
11193
+ try {
11194
+ await channel.clearSession(sessionId);
11195
+ } finally {
11196
+ channel.destroy();
11197
+ }
11198
+ } catch {
11199
+ }
11200
+ return clearActiveStudioSession(sessionId, { force: true });
11201
+ }
11202
+
11006
11203
  // ../bitfab-plugin-lib/dist/replayCapabilities.js
11007
11204
  var semver = __toESM(require_semver2(), 1);
11008
11205
 
@@ -11210,6 +11407,20 @@ function applyFocusTarget(target) {
11210
11407
  }
11211
11408
  }
11212
11409
 
11410
+ // ../bitfab-plugin-lib/dist/studioConnectGrace.js
11411
+ var CONNECT_GRACE_MS = 45e3;
11412
+ function connectGraceMs() {
11413
+ const raw = process.env.BITFAB_STUDIO_CONNECT_GRACE_MS;
11414
+ const parsed = raw ? Number(raw) : Number.NaN;
11415
+ return Number.isFinite(parsed) ? parsed : CONNECT_GRACE_MS;
11416
+ }
11417
+ var PRE_AUTH_CONNECT_GRACE_MS = 10 * 60 * 1e3;
11418
+ function preAuthConnectGraceMs() {
11419
+ const raw = process.env.BITFAB_STUDIO_PREAUTH_CONNECT_GRACE_MS;
11420
+ const parsed = raw ? Number(raw) : Number.NaN;
11421
+ return Number.isFinite(parsed) ? parsed : PRE_AUTH_CONNECT_GRACE_MS;
11422
+ }
11423
+
11213
11424
  // ../bitfab-plugin-lib/dist/commands/openStudioTo.js
11214
11425
  function lineToAgentEvent(line) {
11215
11426
  const name = typeof line.event === "string" ? line.event : "";
@@ -11219,17 +11430,31 @@ function lineToAgentEvent(line) {
11219
11430
  var WINDOW_CLOSE_GRACE_PERIOD_MS = 1e4;
11220
11431
  var LOGIN_TIMEOUT_MS = 10 * 60 * 1e3;
11221
11432
  function formatStudioUrlMessage(url2) {
11222
- return `Studio opened at: ${url2}`;
11433
+ return `Opening Studio at: ${url2}`;
11434
+ }
11435
+ function defaultLifecycleEmit(event) {
11436
+ emitJson(event);
11437
+ if (event.event === "window-open-requested" && typeof event.url === "string") {
11438
+ console.error(formatStudioUrlMessage(event.url));
11439
+ }
11223
11440
  }
11224
- function createEventSubscription(channel, sessionId, onEvent, restoreFocus) {
11441
+ function createEventSubscription(channel, sessionId, onEvent, restoreFocus, freshOpenGraceMs = null) {
11225
11442
  let graceTimer = null;
11443
+ let connectWatchdog = null;
11226
11444
  let endedNotified = false;
11445
+ const ownsPointer = !channel.isPersistent();
11227
11446
  const clearGraceTimer = () => {
11228
11447
  if (graceTimer) {
11229
11448
  clearTimeout(graceTimer);
11230
11449
  graceTimer = null;
11231
11450
  }
11232
11451
  };
11452
+ const clearConnectWatchdog = () => {
11453
+ if (connectWatchdog) {
11454
+ clearTimeout(connectWatchdog);
11455
+ connectWatchdog = null;
11456
+ }
11457
+ };
11233
11458
  const notifyEnded = (baseEvent) => {
11234
11459
  if (!endedNotified) {
11235
11460
  endedNotified = true;
@@ -11237,14 +11462,20 @@ function createEventSubscription(channel, sessionId, onEvent, restoreFocus) {
11237
11462
  onEvent({ ...baseEvent, type: "studio:session-ended" });
11238
11463
  }
11239
11464
  clearGraceTimer();
11465
+ clearConnectWatchdog();
11240
11466
  sub.abort();
11241
11467
  };
11242
11468
  const sub = channel.subscribe(sessionId, (event) => {
11469
+ if (!event.type.startsWith("agent:")) {
11470
+ clearConnectWatchdog();
11471
+ }
11243
11472
  if (event.type === "studio:window-closed") {
11244
11473
  clearGraceTimer();
11245
11474
  graceTimer = setTimeout(() => {
11246
11475
  graceTimer = null;
11247
- updateActiveStudioSession({ windowState: "closed" });
11476
+ if (ownsPointer) {
11477
+ updateActiveStudioSession({ windowState: "closed" });
11478
+ }
11248
11479
  notifyEnded(event);
11249
11480
  }, WINDOW_CLOSE_GRACE_PERIOD_MS);
11250
11481
  return;
@@ -11277,9 +11508,36 @@ function createEventSubscription(channel, sessionId, onEvent, restoreFocus) {
11277
11508
  }, (err) => {
11278
11509
  console.error(`studio event stream: ${err.message}`);
11279
11510
  });
11511
+ if (freshOpenGraceMs != null) {
11512
+ connectWatchdog = setTimeout(() => {
11513
+ connectWatchdog = null;
11514
+ if (endedNotified) {
11515
+ return;
11516
+ }
11517
+ endedNotified = true;
11518
+ restoreFocus();
11519
+ if (ownsPointer) {
11520
+ updateActiveStudioSession({ windowState: "closed" });
11521
+ }
11522
+ onEvent({
11523
+ id: "",
11524
+ type: "studio:session-ended",
11525
+ data: { reason: "never-connected" }
11526
+ });
11527
+ clearGraceTimer();
11528
+ void (async () => {
11529
+ try {
11530
+ await channel.clearSession(sessionId);
11531
+ } catch {
11532
+ }
11533
+ sub.abort();
11534
+ })();
11535
+ }, freshOpenGraceMs);
11536
+ }
11280
11537
  return {
11281
11538
  abort: () => {
11282
11539
  clearGraceTimer();
11540
+ clearConnectWatchdog();
11283
11541
  sub.abort();
11284
11542
  },
11285
11543
  done: sub.done
@@ -11293,7 +11551,19 @@ async function openStudioTo(path21, opts = {}) {
11293
11551
  const focusTarget = captureFocusTarget();
11294
11552
  const restoreFocus = () => applyFocusTarget(focusTarget);
11295
11553
  const channel = await resolveChannel(apiKey ?? "", serviceUrl);
11296
- const finish = (sessionId2, opened2, resolvedApiKey, url3) => {
11554
+ const emit = opts.emit ?? defaultLifecycleEmit;
11555
+ const reportFreshOpen = (url3, launched2, reason) => {
11556
+ if (launched2 === false) {
11557
+ emit({
11558
+ event: "open-failed",
11559
+ reason: reason ?? "could not launch a browser",
11560
+ url: url3
11561
+ });
11562
+ } else {
11563
+ emit({ event: "window-open-requested", url: url3 });
11564
+ }
11565
+ };
11566
+ const finish = (sessionId2, opened2, resolvedApiKey, url3, launched2, windowProven = false) => {
11297
11567
  const persistent = channel.isPersistent();
11298
11568
  const base = {
11299
11569
  sessionId: sessionId2,
@@ -11304,6 +11574,7 @@ async function openStudioTo(path21, opts = {}) {
11304
11574
  url: url3
11305
11575
  };
11306
11576
  const wantsEvents = opts.tail || Boolean(opts.onEvent);
11577
+ const freshOpenGraceMs = opened2 && !windowProven ? launched2 === false ? preAuthConnectGraceMs() : connectGraceMs() : null;
11307
11578
  if (persistent) {
11308
11579
  if (!wantsEvents || opts.tail) {
11309
11580
  if (!opts.tail) {
@@ -11316,7 +11587,7 @@ async function openStudioTo(path21, opts = {}) {
11316
11587
  };
11317
11588
  }
11318
11589
  const subscription = createEventSubscription(channel, sessionId2, opts.onEvent ?? (() => {
11319
- }), restoreFocus);
11590
+ }), restoreFocus, freshOpenGraceMs);
11320
11591
  return { ...base, ...subscription };
11321
11592
  }
11322
11593
  if (!wantsEvents) {
@@ -11334,7 +11605,7 @@ async function openStudioTo(path21, opts = {}) {
11334
11605
  appendStudioEvent(key, studioEventToLogLine(event, sessionId2));
11335
11606
  userOnEvent2(event);
11336
11607
  };
11337
- const subscription = createEventSubscription(channel, sessionId2, sink, restoreFocus);
11608
+ const subscription = createEventSubscription(channel, sessionId2, sink, restoreFocus, freshOpenGraceMs);
11338
11609
  return {
11339
11610
  ...base,
11340
11611
  abort: subscription.abort,
@@ -11378,9 +11649,11 @@ async function openStudioTo(path21, opts = {}) {
11378
11649
  const sessionId2 = openResult.sessionId;
11379
11650
  const signInUrl = `${serviceUrl}${buildSignInPath(sessionId2)}`;
11380
11651
  if (openResult.opened) {
11381
- opts.onWindowOpened?.(signInUrl);
11652
+ reportFreshOpen(signInUrl, openResult.launched, openResult.launchReason);
11653
+ } else {
11654
+ emit({ event: "navigated", sessionId: sessionId2, path: buildSignInPath(sessionId2) });
11382
11655
  }
11383
- opts.onLoginRequired?.(sessionId2, signInUrl);
11656
+ emit({ event: "auth-required", sessionId: sessionId2, signInUrl });
11384
11657
  const loginApiKey = await new Promise((resolve2, reject) => {
11385
11658
  const timer = setTimeout(() => {
11386
11659
  sub.abort();
@@ -11404,19 +11677,30 @@ async function openStudioTo(path21, opts = {}) {
11404
11677
  });
11405
11678
  saveCredentials(loginApiKey);
11406
11679
  updateActiveStudioSession({ authenticated: true });
11407
- opts.onAuthenticated?.(sessionId2);
11680
+ emit({ event: "authenticated", sessionId: sessionId2 });
11408
11681
  channel.setApiKey(loginApiKey);
11409
- return finish(sessionId2, openResult.opened, loginApiKey, openResult.opened ? signInUrl : void 0);
11682
+ return finish(
11683
+ sessionId2,
11684
+ openResult.opened,
11685
+ loginApiKey,
11686
+ openResult.opened ? signInUrl : void 0,
11687
+ openResult.launched,
11688
+ // The sign-in page pushed studio:authenticated to get here, so the
11689
+ // window demonstrably exists: never arm the connect watchdog on it.
11690
+ true
11691
+ );
11410
11692
  }
11411
- const { sessionId, opened } = await channel.openOrNavigate(path21, {
11693
+ const { sessionId, opened, launched, launchReason } = await channel.openOrNavigate(path21, {
11412
11694
  clientHeaders: opts.clientHeaders,
11413
11695
  focusTarget: focusTarget ?? void 0
11414
11696
  });
11415
11697
  const url2 = opened ? resolveStudioSessionTarget({ serviceUrl, path: path21, sessionId }).url : void 0;
11416
- if (url2) {
11417
- opts.onWindowOpened?.(url2);
11698
+ if (opened && url2) {
11699
+ reportFreshOpen(url2, launched, launchReason);
11700
+ } else {
11701
+ emit({ event: "navigated", sessionId, path: path21 });
11418
11702
  }
11419
- return finish(sessionId, opened, apiKey, url2);
11703
+ return finish(sessionId, opened, apiKey, url2, launched);
11420
11704
  }
11421
11705
 
11422
11706
  // ../bitfab-plugin-lib/dist/commands/login.js
@@ -11451,20 +11735,33 @@ Already logged in as ${identity}${endpoint}. Run the login command with --force
11451
11735
  return;
11452
11736
  }
11453
11737
  }
11738
+ if (force) {
11739
+ const record2 = readActiveStudioSession();
11740
+ if (record2) {
11741
+ await clearStudioSessionById(record2.sessionId, record2).catch(() => {
11742
+ });
11743
+ }
11744
+ }
11454
11745
  try {
11455
11746
  let printedSignInUrl = false;
11456
11747
  const result = await openStudioTo("/studio", {
11457
11748
  forceLogin: true,
11458
11749
  freshWindowPath: `/studio/close?autoClose=true&message=${encodeURIComponent("Login complete")}`,
11459
- onLoginRequired: (_sessionId, signInUrl) => {
11460
- if (!printedSignInUrl) {
11750
+ // login is a human-facing command, not a JSONL stream, so it renders the
11751
+ // Studio lifecycle events as text instead of the default stdout JSON. It
11752
+ // only cares about surfacing a sign-in link (once) and reporting a failed
11753
+ // browser launch; navigated/authenticated are handled by the flow below.
11754
+ emit: (event) => {
11755
+ if (event.event === "open-failed") {
11461
11756
  printedSignInUrl = true;
11462
- console.log(formatStudioUrlMessage(signInUrl));
11757
+ console.log(`Could not open a browser (${event.reason}). Open this link to sign in: ${event.url}`);
11758
+ return;
11759
+ }
11760
+ const url2 = event.event === "window-open-requested" ? event.url : event.event === "auth-required" ? event.signInUrl : null;
11761
+ if (url2 && !printedSignInUrl) {
11762
+ printedSignInUrl = true;
11763
+ console.log(formatStudioUrlMessage(url2));
11463
11764
  }
11464
- },
11465
- onWindowOpened: (url2) => {
11466
- printedSignInUrl = true;
11467
- console.log(formatStudioUrlMessage(url2));
11468
11765
  }
11469
11766
  });
11470
11767
  const apiKey = getConfig().apiKey;
@@ -11495,7 +11792,7 @@ ${greeting}`);
11495
11792
  } catch (err) {
11496
11793
  if (exitOnComplete) {
11497
11794
  if (err instanceof StudioNavigationError && err.staleSessionId) {
11498
- console.error("\nA Studio window is recorded as open but is not responding. Close it (or run the clearStudioSession command), then try again.");
11795
+ console.error("\nA Studio window is recorded as open but is not responding. Close the Studio window and try again. To force-clear a stale session, a user can re-run with `bitfab login --force` (manual recovery - automated agents should not run it).");
11499
11796
  } else {
11500
11797
  console.error(`
11501
11798
  ${err.message}`);
@@ -33004,7 +33301,7 @@ var GLOBAL_HELP_TEXT = `Usage: bitfab <command> [options]
33004
33301
  Commands:
33005
33302
  init [--editor <name>] Full onboarding: plugin-install, login, and setup
33006
33303
  plugin-install [--editor <name>] Install the Bitfab plugin in one editor
33007
- login Authenticate with Bitfab (opens browser)
33304
+ login [--force] Authenticate with Bitfab (opens browser)
33008
33305
  logout Remove stored credentials
33009
33306
  session-logs [status|enable|disable] Read or update session log collection
33010
33307
  setup [--editor <name>] Launch /bitfab:setup in the editor
@@ -33021,6 +33318,7 @@ Options:
33021
33318
  --no-upload-logs analyze-repo: keep session logs local (skips the prompt)
33022
33319
  --limit <n> analyze-repo: cap how many draft trace plans to upload (default 5)
33023
33320
  --prompt, -p <text> analyze-repo: free-text guidance steering what to focus on
33321
+ --force login: re-authenticate and clear any stale Studio session first
33024
33322
 
33025
33323
  Examples:
33026
33324
  bitfab init Full setup (detect editor, install, login, setup)
@@ -33028,6 +33326,7 @@ Examples:
33028
33326
  bitfab analyze-repo Scan the repo and upload draft trace plans (headless)
33029
33327
  bitfab analyze-repo --limit 3 Scan the repo and upload at most 3 draft trace plans
33030
33328
  bitfab analyze-repo "focus on billing" Scan with free-text guidance on what to prioritize
33329
+ bitfab login --force Re-authenticate and clear a stale Studio session
33031
33330
  bitfab session-logs enable Enable session log collection
33032
33331
  bitfab session-logs disable Disable session log collection
33033
33332
  bitfab assistant investigate Investigate traces
@@ -33060,15 +33359,16 @@ Examples:
33060
33359
  bitfab plugin-install
33061
33360
  bitfab plugin-install --editor cursor
33062
33361
  `,
33063
- login: `Usage: bitfab login
33362
+ login: `Usage: bitfab login [--force]
33064
33363
 
33065
33364
  Authenticate with Bitfab in the browser and save credentials locally.
33066
33365
 
33067
33366
  Options:
33068
- This command has no options.
33367
+ --force Re-authenticate even when already signed in, and clear any stale Studio session first
33069
33368
 
33070
33369
  Examples:
33071
33370
  bitfab login
33371
+ bitfab login --force
33072
33372
  `,
33073
33373
  logout: `Usage: bitfab logout
33074
33374
 
@@ -33230,7 +33530,7 @@ function wantsHelp(argv) {
33230
33530
  var COMMAND_SPECS = {
33231
33531
  init: { flags: ["editor", "skipPermissions"], unknownOption: "error" },
33232
33532
  "plugin-install": { flags: ["editor"], unknownOption: "error" },
33233
- login: { flags: [], unknownOption: "error" },
33533
+ login: { flags: ["force"], unknownOption: "error" },
33234
33534
  logout: { flags: [], unknownOption: "error" },
33235
33535
  "session-logs": { flags: [], unknownOption: "error" },
33236
33536
  setup: { flags: ["editor", "skipPermissions"], unknownOption: "error" },
@@ -33304,6 +33604,12 @@ function applyFlag(key, token, next, values) {
33304
33604
  return 1;
33305
33605
  }
33306
33606
  return null;
33607
+ case "force":
33608
+ if (token === "--force") {
33609
+ values.force = true;
33610
+ return 1;
33611
+ }
33612
+ return null;
33307
33613
  }
33308
33614
  }
33309
33615
  function parseArgs2(argv) {
@@ -33350,6 +33656,7 @@ function parseArgs2(argv) {
33350
33656
  uploadLogs: values.uploadLogs,
33351
33657
  limit: values.limit,
33352
33658
  prompt: values.prompt,
33659
+ force: values.force,
33353
33660
  rest
33354
33661
  };
33355
33662
  }
@@ -33369,7 +33676,16 @@ ${GLOBAL_HELP_TEXT}`
33369
33676
  process.stdout.write(helpText);
33370
33677
  return;
33371
33678
  }
33372
- const { command, editor, skipPermissions, uploadLogs, limit, prompt, rest } = parseArgs2(argv);
33679
+ const {
33680
+ command,
33681
+ editor,
33682
+ skipPermissions,
33683
+ uploadLogs,
33684
+ limit,
33685
+ prompt,
33686
+ force,
33687
+ rest
33688
+ } = parseArgs2(argv);
33373
33689
  const abortUpdateCheck = startUpdateCheck();
33374
33690
  if (command === "init") {
33375
33691
  await runInit({ editor, skipPermissions });
@@ -33382,7 +33698,7 @@ ${GLOBAL_HELP_TEXT}`
33382
33698
  return;
33383
33699
  }
33384
33700
  if (command === "login") {
33385
- await runLoginCommand();
33701
+ await runLoginCommand({ force });
33386
33702
  abortUpdateCheck();
33387
33703
  return;
33388
33704
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bitfab-cli",
3
- "version": "0.2.186",
3
+ "version": "0.2.188",
4
4
  "description": "Install and configure the Bitfab plugin in Claude Code, Codex, or Cursor.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",