flightdesk 0.2.1 → 0.2.2

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 (3) hide show
  1. package/main.js +260 -62
  2. package/main.js.map +2 -2
  3. package/package.json +1 -1
package/main.js CHANGED
@@ -3579,6 +3579,86 @@ var fs2 = __toESM(require("node:fs"));
3579
3579
  var readline2 = __toESM(require("node:readline"));
3580
3580
  var playwright = null;
3581
3581
  var USER_DATA_DIR = path2.join(os2.homedir(), ".flightdesk", "chromium-profile");
3582
+ var STORAGE_STATE_FILE = path2.join(os2.homedir(), ".flightdesk", "auth-state.json");
3583
+ var PersistentBrowser = class {
3584
+ constructor(headless = true) {
3585
+ this.browser = null;
3586
+ this.context = null;
3587
+ this.page = null;
3588
+ this.headless = headless;
3589
+ }
3590
+ /**
3591
+ * Initialize the browser context (if not already initialized)
3592
+ */
3593
+ async init() {
3594
+ if (this.context) return;
3595
+ if (!await isPlaywrightAvailable()) {
3596
+ throw new Error("Playwright not available");
3597
+ }
3598
+ ensureUserDataDir();
3599
+ this.browser = await playwright.chromium.launch({
3600
+ headless: this.headless
3601
+ });
3602
+ const hasAuthState = fs2.existsSync(STORAGE_STATE_FILE);
3603
+ const contextOptions = {
3604
+ viewport: { width: 1280, height: 720 },
3605
+ userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
3606
+ };
3607
+ if (hasAuthState) {
3608
+ contextOptions.storageState = STORAGE_STATE_FILE;
3609
+ }
3610
+ this.context = await this.browser.newContext(contextOptions);
3611
+ this.page = await this.context.newPage();
3612
+ this.page.setDefaultTimeout(3e4);
3613
+ }
3614
+ /**
3615
+ * Check if user is logged into Claude
3616
+ */
3617
+ async checkAuth() {
3618
+ await this.init();
3619
+ try {
3620
+ await this.page.goto("https://claude.ai/", { waitUntil: "networkidle", timeout: 3e4 });
3621
+ await this.page.waitForTimeout(2e3);
3622
+ const url = this.page.url();
3623
+ console.log(" Final URL:", url);
3624
+ return !url.includes("/login") && !url.includes("/oauth") && !url.includes("accounts.google") && url.includes("claude.ai");
3625
+ } catch (error) {
3626
+ console.error("Auth check failed:", error.message);
3627
+ return false;
3628
+ }
3629
+ }
3630
+ /**
3631
+ * Monitor a Claude Code session URL
3632
+ * Uses shared scrapeSession helper for consistent behavior with one-shot mode.
3633
+ */
3634
+ async monitorSession(sessionUrl, options = {}) {
3635
+ await this.init();
3636
+ if (!this.page) {
3637
+ return { status: "error", error: "Browser page not initialized" };
3638
+ }
3639
+ return scrapeSession(this.page, sessionUrl, options);
3640
+ }
3641
+ /**
3642
+ * Close the browser context and browser
3643
+ */
3644
+ async close() {
3645
+ if (this.context) {
3646
+ try {
3647
+ await this.context.close();
3648
+ } catch {
3649
+ }
3650
+ this.context = null;
3651
+ this.page = null;
3652
+ }
3653
+ if (this.browser) {
3654
+ try {
3655
+ await this.browser.close();
3656
+ } catch {
3657
+ }
3658
+ this.browser = null;
3659
+ }
3660
+ }
3661
+ };
3582
3662
  async function isPlaywrightAvailable() {
3583
3663
  try {
3584
3664
  playwright = await import("playwright");
@@ -3589,7 +3669,23 @@ async function isPlaywrightAvailable() {
3589
3669
  }
3590
3670
  function ensureUserDataDir() {
3591
3671
  if (!fs2.existsSync(USER_DATA_DIR)) {
3592
- fs2.mkdirSync(USER_DATA_DIR, { recursive: true });
3672
+ fs2.mkdirSync(USER_DATA_DIR, { recursive: true, mode: 448 });
3673
+ }
3674
+ }
3675
+ async function ensureStorageDir() {
3676
+ const storageDir = path2.dirname(STORAGE_STATE_FILE);
3677
+ try {
3678
+ await fs2.promises.mkdir(storageDir, { recursive: true, mode: 448 });
3679
+ } catch (err) {
3680
+ if (err?.code !== "EEXIST") {
3681
+ throw err;
3682
+ }
3683
+ }
3684
+ }
3685
+ async function setStorageFilePermissions() {
3686
+ try {
3687
+ await fs2.promises.chmod(STORAGE_STATE_FILE, 384);
3688
+ } catch {
3593
3689
  }
3594
3690
  }
3595
3691
  async function launchBrowser(headless) {
@@ -3597,24 +3693,28 @@ async function launchBrowser(headless) {
3597
3693
  throw new Error("Playwright not available");
3598
3694
  }
3599
3695
  ensureUserDataDir();
3600
- const context = await playwright.chromium.launchPersistentContext(USER_DATA_DIR, {
3601
- headless,
3696
+ const browser = await playwright.chromium.launch({ headless });
3697
+ const hasAuthState = fs2.existsSync(STORAGE_STATE_FILE);
3698
+ const contextOptions = {
3602
3699
  viewport: { width: 1280, height: 720 },
3603
3700
  userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
3604
- });
3605
- const browser = context.browser();
3701
+ };
3702
+ if (hasAuthState) {
3703
+ contextOptions.storageState = STORAGE_STATE_FILE;
3704
+ }
3705
+ const context = await browser.newContext(contextOptions);
3606
3706
  return { browser, context };
3607
3707
  }
3608
3708
  async function checkAuth() {
3609
3709
  if (!await isPlaywrightAvailable()) {
3610
3710
  throw new Error("Playwright not installed");
3611
3711
  }
3612
- const { context } = await launchBrowser(true);
3712
+ const { browser, context } = await launchBrowser(true);
3613
3713
  try {
3614
3714
  const page = await context.newPage();
3615
3715
  page.setDefaultTimeout(3e4);
3616
- await page.goto("https://claude.ai/", { waitUntil: "domcontentloaded", timeout: 3e4 });
3617
- await page.waitForTimeout(3e3);
3716
+ await page.goto("https://claude.ai/", { waitUntil: "networkidle", timeout: 3e4 });
3717
+ await page.waitForTimeout(2e3);
3618
3718
  const url = page.url();
3619
3719
  console.log(" Final URL:", url);
3620
3720
  const isLoggedIn = !url.includes("/login") && !url.includes("/oauth") && !url.includes("accounts.google") && url.includes("claude.ai");
@@ -3624,6 +3724,7 @@ async function checkAuth() {
3624
3724
  return false;
3625
3725
  } finally {
3626
3726
  await context.close();
3727
+ await browser.close();
3627
3728
  }
3628
3729
  }
3629
3730
  async function openForLogin() {
@@ -3643,6 +3744,13 @@ async function openForLogin() {
3643
3744
  const url = page.url();
3644
3745
  console.log(" Final URL:", url);
3645
3746
  const isLoggedIn = !url.includes("/login") && !url.includes("/oauth") && !url.includes("accounts.google") && url.includes("claude.ai");
3747
+ if (isLoggedIn) {
3748
+ await ensureStorageDir();
3749
+ console.log("Saving session state...");
3750
+ await context.storageState({ path: STORAGE_STATE_FILE });
3751
+ await setStorageFilePermissions();
3752
+ console.log(` Saved to: ${STORAGE_STATE_FILE}`);
3753
+ }
3646
3754
  console.log("Closing browser...");
3647
3755
  return isLoggedIn;
3648
3756
  } finally {
@@ -3664,17 +3772,15 @@ function waitForEnter() {
3664
3772
  });
3665
3773
  });
3666
3774
  }
3667
- async function monitorSession(sessionUrl, options = {}) {
3668
- if (!await isPlaywrightAvailable()) {
3669
- return { status: "error", error: "Playwright not installed" };
3670
- }
3671
- const { headless = true, timeout = 3e4, autoPr = false } = options;
3672
- const { context } = await launchBrowser(headless);
3775
+ async function scrapeSession(page, sessionUrl, options = {}) {
3776
+ const { timeout = 3e4, autoPr = false } = options;
3673
3777
  try {
3674
- const page = await context.newPage();
3675
3778
  page.setDefaultTimeout(timeout);
3676
- await page.goto(sessionUrl, { waitUntil: "domcontentloaded" });
3677
- await page.waitForTimeout(2e3);
3779
+ await page.goto(sessionUrl, { waitUntil: "networkidle" });
3780
+ await page.waitForSelector('[data-testid="conversation-turn"], .code-spinner-animate, button:has-text("Create PR")', {
3781
+ timeout: 1e4
3782
+ }).catch(() => {
3783
+ });
3678
3784
  const result = { status: "active" };
3679
3785
  const archiveIndicator = await page.$("text=This session has been archived");
3680
3786
  if (archiveIndicator) {
@@ -3687,15 +3793,18 @@ async function monitorSession(sessionUrl, options = {}) {
3687
3793
  error: "Not logged in to Claude. Run: flightdesk auth"
3688
3794
  };
3689
3795
  }
3796
+ const isActive = await detectActiveSpinner(page);
3797
+ result.isActive = isActive;
3690
3798
  const branchName = await extractBranchName(page);
3691
3799
  if (branchName) {
3692
3800
  result.branchName = branchName;
3693
3801
  }
3694
- const prButton = await page.$('button:has-text("Create PR"):not([aria-haspopup])');
3695
- result.hasPrButton = !!prButton;
3696
- if (autoPr && prButton) {
3802
+ const createPrButton = await page.$('button:has-text("Create PR"):not([aria-haspopup])');
3803
+ const viewPrButton = await page.$('button:has-text("View PR")');
3804
+ result.hasPrButton = !!createPrButton && !viewPrButton;
3805
+ if (autoPr && createPrButton && !viewPrButton) {
3697
3806
  console.log(' Clicking "Create PR" button...');
3698
- await prButton.click();
3807
+ await createPrButton.click();
3699
3808
  await page.waitForTimeout(5e3);
3700
3809
  const prUrl = await extractPrUrl(page);
3701
3810
  if (prUrl) {
@@ -3708,29 +3817,54 @@ async function monitorSession(sessionUrl, options = {}) {
3708
3817
  status: "error",
3709
3818
  error: error instanceof Error ? error.message : String(error)
3710
3819
  };
3711
- } finally {
3712
- await context.close();
3820
+ }
3821
+ }
3822
+ function isValidBranchName(text) {
3823
+ if (!text) return false;
3824
+ const cleaned = text.trim();
3825
+ return cleaned.length > 2 && !cleaned.includes(" ");
3826
+ }
3827
+ function matchesBranchPattern(text) {
3828
+ return /^[a-zA-Z0-9_-]+\/[a-zA-Z0-9_-]+/.test(text) && !text.includes(" ");
3829
+ }
3830
+ async function tryExtractFromElement(page, selector) {
3831
+ try {
3832
+ const element = await page.$(selector);
3833
+ if (!element) return null;
3834
+ const text = await element.textContent();
3835
+ const cleaned = text?.trim() ?? null;
3836
+ return isValidBranchName(cleaned) ? cleaned : null;
3837
+ } catch {
3838
+ return null;
3713
3839
  }
3714
3840
  }
3715
3841
  async function extractBranchName(page) {
3842
+ if (!page) return null;
3716
3843
  const primarySelectors = [
3717
- String.raw`button.group\/copy span.truncate`
3844
+ String.raw`button.group\/copy span.truncate`,
3718
3845
  // New branch name (verified)
3846
+ 'button[class*="group/copy"] span.truncate',
3847
+ // Alternative escaping
3848
+ String.raw`button[class*="group\/copy"] span.truncate`
3849
+ // CSS escape
3719
3850
  ];
3720
3851
  for (const selector of primarySelectors) {
3721
- try {
3722
- const element = await page.$(selector);
3723
- if (element) {
3724
- const text = await element.textContent();
3725
- if (text) {
3726
- const cleaned = text.trim();
3727
- if (cleaned && cleaned.length > 2 && !cleaned.includes(" ")) {
3728
- return cleaned;
3729
- }
3730
- }
3852
+ const result = await tryExtractFromElement(page, selector);
3853
+ if (result) return result;
3854
+ }
3855
+ return await tryExtractBranchFromSpans(page);
3856
+ }
3857
+ async function tryExtractBranchFromSpans(page) {
3858
+ try {
3859
+ const allSpans = await page.$$("span.truncate");
3860
+ for (const span of allSpans) {
3861
+ const text = await span.textContent();
3862
+ const cleaned = text?.trim();
3863
+ if (cleaned && matchesBranchPattern(cleaned)) {
3864
+ return cleaned;
3731
3865
  }
3732
- } catch {
3733
3866
  }
3867
+ } catch {
3734
3868
  }
3735
3869
  return null;
3736
3870
  }
@@ -3768,6 +3902,36 @@ async function extractPrUrl(page) {
3768
3902
  }
3769
3903
  return null;
3770
3904
  }
3905
+ async function detectActiveSpinner(page) {
3906
+ try {
3907
+ const spinner = await page.$(".code-spinner-animate");
3908
+ if (spinner) {
3909
+ return true;
3910
+ }
3911
+ const spinnerByContent = await page.$('span:has-text("\u273D")');
3912
+ if (spinnerByContent) {
3913
+ const hasAnimation = await spinnerByContent.evaluate((el) => {
3914
+ let current = el;
3915
+ while (current) {
3916
+ const classList = current.classList;
3917
+ if (classList?.contains("code-spinner-animate")) {
3918
+ return true;
3919
+ }
3920
+ const style = globalThis.getComputedStyle(current);
3921
+ if (style.animationName && style.animationName !== "none") {
3922
+ return true;
3923
+ }
3924
+ current = current.parentElement;
3925
+ }
3926
+ return false;
3927
+ });
3928
+ return hasAnimation;
3929
+ }
3930
+ return false;
3931
+ } catch {
3932
+ return false;
3933
+ }
3934
+ }
3771
3935
 
3772
3936
  // apps/cli/src/commands/auth.ts
3773
3937
  async function authCommand() {
@@ -4066,17 +4230,23 @@ async function watchCommand(options) {
4066
4230
  console.log(` Auto-PR: ${options.autoPr ? "enabled" : "disabled"}`);
4067
4231
  console.log("");
4068
4232
  const playwrightAvailable = await isPlaywrightAvailable();
4233
+ let browser = null;
4069
4234
  if (!playwrightAvailable) {
4070
4235
  console.log("\u26A0\uFE0F Playwright not installed. Session monitoring disabled.");
4071
4236
  console.log(" Install with: pnpm add playwright && npx playwright install chromium");
4072
4237
  console.log("");
4073
4238
  } else {
4074
- const isAuthenticated = await checkAuth();
4239
+ browser = new PersistentBrowser(options.headless !== false);
4240
+ console.log("Checking Claude authentication...");
4241
+ const isAuthenticated = await browser.checkAuth();
4075
4242
  if (!isAuthenticated) {
4076
4243
  console.log("\u26A0\uFE0F Not logged into Claude. Run: flightdesk auth");
4077
4244
  console.log("");
4245
+ await browser.close();
4246
+ browser = null;
4078
4247
  } else {
4079
4248
  console.log("\u2705 Playwright ready, Claude authenticated");
4249
+ console.log(" (Browser session kept alive for monitoring)");
4080
4250
  console.log("");
4081
4251
  }
4082
4252
  }
@@ -4111,38 +4281,49 @@ async function watchCommand(options) {
4111
4281
  if (reconciled) {
4112
4282
  continue;
4113
4283
  }
4114
- if (task2.sessionViewUrl && playwrightAvailable) {
4284
+ if (task2.sessionViewUrl && browser) {
4115
4285
  console.log(` Checking session...`);
4116
- const sessionInfo = await monitorSession(task2.sessionViewUrl, {
4117
- headless: options.headless !== false,
4286
+ const sessionInfo = await browser.monitorSession(task2.sessionViewUrl, {
4118
4287
  autoPr: options.autoPr && !task2.prUrl
4119
4288
  // Only auto-PR if no PR exists
4120
4289
  });
4121
4290
  await processSessionInfo(api, task2, sessionInfo);
4122
4291
  } else if (!task2.sessionViewUrl) {
4123
4292
  console.log(" No session URL registered");
4293
+ } else if (!browser) {
4294
+ console.log(" Browser not available - cannot monitor session");
4124
4295
  }
4125
4296
  }
4126
4297
  } catch (error) {
4127
4298
  console.error(` Error: ${error}`);
4128
4299
  }
4129
4300
  }
4130
- await runCheck();
4131
- if (options.once) {
4132
- console.log("\nDone (--once flag specified)");
4133
- return;
4134
- }
4135
- console.log(`
4301
+ try {
4302
+ await runCheck();
4303
+ if (options.once) {
4304
+ console.log("\nDone (--once flag specified)");
4305
+ return;
4306
+ }
4307
+ console.log(`
4136
4308
  Watching... (Ctrl+C to stop)
4137
4309
  `);
4138
- const intervalId = setInterval(runCheck, intervalMinutes * 60 * 1e3);
4139
- process.on("SIGINT", () => {
4140
- console.log("\nShutting down...");
4141
- clearInterval(intervalId);
4142
- process.exit(0);
4143
- });
4144
- await new Promise((_resolve) => {
4145
- });
4310
+ const intervalId = setInterval(runCheck, intervalMinutes * 60 * 1e3);
4311
+ process.on("SIGINT", async () => {
4312
+ console.log("\nShutting down...");
4313
+ clearInterval(intervalId);
4314
+ if (browser) {
4315
+ console.log("Closing browser...");
4316
+ await browser.close();
4317
+ }
4318
+ process.exit(0);
4319
+ });
4320
+ await new Promise((_resolve) => {
4321
+ });
4322
+ } finally {
4323
+ if (browser) {
4324
+ await browser.close();
4325
+ }
4326
+ }
4146
4327
  }
4147
4328
  async function reconcileTaskStatus(api, task2) {
4148
4329
  let expectedStatus = null;
@@ -4186,7 +4367,25 @@ async function processSessionInfo(api, task2, info) {
4186
4367
  console.log(" \u{1F4E6} Session archived");
4187
4368
  return;
4188
4369
  }
4189
- const updates = {};
4370
+ let activityIcon;
4371
+ let activityLabel;
4372
+ if (info.isActive === true) {
4373
+ activityIcon = "\u26A1";
4374
+ activityLabel = "Claude is working";
4375
+ } else if (info.isActive === false) {
4376
+ activityIcon = "\u{1F4A4}";
4377
+ activityLabel = "Waiting for input";
4378
+ } else {
4379
+ activityIcon = "\u2754";
4380
+ activityLabel = "Activity unknown";
4381
+ }
4382
+ console.log(` ${activityIcon} ${activityLabel}`);
4383
+ const updates = {
4384
+ // Update session activity state and timestamp
4385
+ // Preserve null when activity detection fails (info.isActive is undefined)
4386
+ sessionActive: info.isActive ?? null,
4387
+ sessionCheckedAt: (/* @__PURE__ */ new Date()).toISOString()
4388
+ };
4190
4389
  if (info.branchName && info.branchName !== task2.branchName) {
4191
4390
  console.log(` \u{1F33F} Branch detected: ${info.branchName}`);
4192
4391
  updates.branchName = info.branchName;
@@ -4206,15 +4405,14 @@ async function processSessionInfo(api, task2, info) {
4206
4405
  if (info.hasPrButton && !info.prUrl && !task2.prUrl) {
4207
4406
  console.log(' \u{1F4DD} "Create PR" button available');
4208
4407
  }
4209
- if (Object.keys(updates).length > 0) {
4210
- try {
4211
- await api.updateTask(task2.id, updates);
4408
+ const hasNonActivityUpdates = Object.keys(updates).some((k) => !["sessionActive", "sessionCheckedAt"].includes(k));
4409
+ try {
4410
+ await api.updateTask(task2.id, updates);
4411
+ if (hasNonActivityUpdates) {
4212
4412
  console.log(" \u2705 Task updated");
4213
- } catch (error) {
4214
- console.log(` \u274C Failed to update task: ${error}`);
4215
4413
  }
4216
- } else {
4217
- console.log(" No changes detected");
4414
+ } catch (error) {
4415
+ console.log(` \u274C Failed to update task: ${error}`);
4218
4416
  }
4219
4417
  }
4220
4418
 
@@ -5300,7 +5498,7 @@ async function handleTeardown(api, options) {
5300
5498
 
5301
5499
  // apps/cli/src/main.ts
5302
5500
  var program2 = new Command();
5303
- program2.name("flightdesk").description("FlightDesk CLI - AI task management for Claude Code sessions").version("0.2.1").option("--dev", "Use local development API (localhost:3000)").option("--api <url>", "Use custom API URL");
5501
+ program2.name("flightdesk").description("FlightDesk CLI - AI task management for Claude Code sessions").version("0.2.2").option("--dev", "Use local development API (localhost:3000)").option("--api <url>", "Use custom API URL");
5304
5502
  program2.hook("preAction", () => {
5305
5503
  const opts = program2.opts();
5306
5504
  if (opts.api) {