litmus-cli 1.4.46 → 1.4.48

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.
@@ -16,7 +16,7 @@ import { parseEditorVersion, parseWorkspaceProbe, remoteProbeCommand, seedVerdic
16
16
  import { fatal, success, info, warn, setErrorContext } from "../lib/errors.js";
17
17
  import { oldWindowsSshHint, parseOpenSshVersion, proxyJumpBroken, readEditorSettings, readSshVersionBanner, sshExecutableFor, sshPreflight, sshPreflightHint, } from "../lib/ssh-client.js";
18
18
  import { editorEndingForewarning } from "../lib/session-end.js";
19
- import { announceCursorAutoRestart, attemptQuit, platformSupportsGracefulCursorRestart, realCursorProcessIo, renderCursorRestartOutcome, } from "../lib/cursor-restart.js";
19
+ import { attemptQuit, attemptRelaunch, platformSupportsGracefulCursorRestart, realCursorProcessIo, } from "../lib/cursor-restart.js";
20
20
  /**
21
21
  * `litmus connect <token>` — the CLI connector for the v2 codespace, modeled on
22
22
  * `gh codespace ssh/code`. It removes the manual "paste your public key + copy
@@ -371,7 +371,7 @@ export const NATIVE_EDITORS = {
371
371
  downloadUrl: "https://cursor.com/download",
372
372
  seedCommitPath: "/opt/cursor-server-cache/COMMIT",
373
373
  serverHomeDir: ".cursor-server",
374
- requiresSeedMatch: true,
374
+ requiresSeedMatch: false,
375
375
  },
376
376
  };
377
377
  /**
@@ -492,344 +492,147 @@ export function identifyEditorCli(ed, command, run = runEditorHelp) {
492
492
  return { kind: "unverified" };
493
493
  return actual === ed.ide ? { kind: "match" } : { kind: "mismatch", actual };
494
494
  }
495
+ /**
496
+ * ENG-2890 — the copy rules every connect outcome below follows.
497
+ *
498
+ * A hiring manager watched a candidate fail to open Cursor behind a ~41-line notice,
499
+ * quit and retry several times, and get nowhere. So each outcome is a few lines, the
500
+ * action first, with no mechanism words, and NO CREDENTIAL: the connect token and the
501
+ * browser-IDE URL (which carries `t=`) are never printed. The way back is "run the same
502
+ * litmus connect command again", which is in the candidate's shell history, and the
503
+ * assessment page, whose URL carries only the assessment id (`candidatePageUrl`).
504
+ */
505
+ const SAME_COMMAND = "run the same litmus connect command";
506
+ /**
507
+ * The route that needs nothing else to work: the browser IDE, reached from the page,
508
+ * or the alias we have just written into their ~/.ssh/config.
509
+ */
510
+ export function workNowLine(alias, pageUrl) {
511
+ const page = pageUrl
512
+ ? `the browser IDE on your assessment page (${chalk.cyan(pageUrl)})`
513
+ : "the browser IDE on your assessment page";
514
+ return ` Or work now: ${page}, or ${chalk.cyan(`ssh ${alias}`)}.`;
515
+ }
516
+ /** Where the long form went. Not printed when it is already being shown. */
517
+ function detailsLine(verbose) {
518
+ return verbose ? [] : [chalk.dim(" Details: add --verbose.")];
519
+ }
495
520
  /**
496
521
  * ENG-2152 — the refusal, when the command the candidate's PATH answers with is the
497
- * other editor.
498
- *
499
- * Two routes, and the one that WORKS RIGHT NOW comes first: on a machine where `code` is
500
- * Cursor, Cursor is almost certainly the daily driver and VS Code may not be installed at
501
- * all, so leading with "fix your PATH" leads with the thing that may be impossible.
502
- * That ordering is the same one `announceCursorFullQuit` documents — what a candidate can
503
- * act on immediately is what they read first.
504
- *
505
- * The offered command carries the token already inlined, for the reason `CursorReentry`
506
- * gives: a `litmus connect <token>` a candidate has to repair themselves is worse than no
507
- * command at all.
508
- *
509
- * When the editor that answered is Cursor, the quit requirement rides along. It is the
510
- * headline only, not the whole ENG-1728 notice, because the command being offered prints
511
- * that notice in full at selection time before anything opens — this is a FORWARD
512
- * reference to output the candidate is about to see, not ENG-2145's back-reference to
513
- * output that has scrolled away. Restating the whole block here would also have to
514
- * restate its "this command is still running and will open Cursor for you", which on
515
- * this path is false.
522
+ * other editor. Nothing has been opened and no extension installed when this prints.
523
+ *
524
+ * The editor that answered comes first: on a machine where `code` is Cursor, Cursor is
525
+ * almost certainly the daily driver and VS Code may not be installed at all. The PATH
526
+ * fix follows in one line, then the routes that need neither.
516
527
  */
517
- export function editorMismatchLines(chosen, actual, reentry = CURSOR_REENTRY_FALLBACK) {
518
- const lines = [
519
- "",
520
- chalk.yellow.bold(` ⚠ The \`${chosen.bin}\` command on your PATH is ${actual.label}, not ${chosen.label}.`),
521
- "",
522
- ` You asked for ${chosen.label}, so nothing has been opened and no extension has been`,
523
- ` installed. Working in ${actual.label} while you believe you are in ${chosen.label} would`,
524
- " leave your AI work unrecorded, so this stops here instead.",
525
- "",
526
- " Your workspace is up and waiting — nothing has been lost.",
527
- "",
528
- ` To carry on in ${actual.label} — which is what \`${chosen.bin}\` actually is — run:`,
529
- "",
530
- ` ${chalk.cyan(`${reentry.command} --ide ${actual.ide}`)}`,
528
+ export function editorMismatchLines(chosen, actual, alias, pageUrl) {
529
+ return [
530
+ chalk.yellow.bold(`⚠ The \`${chosen.bin}\` command on your PATH is ${actual.label}, not ${chosen.label}. Nothing was opened.`),
531
+ ` To use ${actual.label}: ${SAME_COMMAND} with ${chalk.cyan(`--ide ${actual.ide}`)}.`,
532
+ ` To use ${chosen.label}: in ${chosen.label}, run "${chosen.shellCommand}" from the Command Palette, open a new terminal, then ${SAME_COMMAND} again.`,
533
+ workNowLine(alias, pageUrl),
531
534
  ];
532
- if (actual.ide === "cursor") {
533
- lines.push("");
534
- lines.push(chalk.yellow(" ⚠ Quit Cursor completely before you run that, or your work is not recorded."));
535
- lines.push(" That command explains why, in full, before anything opens.");
536
- }
537
- lines.push("");
538
- lines.push(` To carry on in ${chosen.label} instead, put its own \`${chosen.bin}\` command first on`);
539
- lines.push(` your PATH: in ${chosen.label}, run "${chosen.shellCommand}" from the`);
540
- lines.push(" Command Palette, then open a NEW terminal and check that");
541
- lines.push(` \`${chosen.bin} --help\` names ${chosen.label}. Then run the same \`litmus connect\` command again.`);
542
- lines.push("");
543
- return lines;
544
535
  }
545
536
  /**
546
- * ENG-2332 — the refusal when the candidate's editor is a different build from the one
547
- * this workspace has, and the route to the workspace that still works.
548
- *
549
- * What it must never do is offer a way round the refusal: there is no flag and no "open
550
- * it anyway", since the thing on the other side of that door is a window that looks
551
- * completely normal and records nothing (`lib/editor-server.ts` carries the measurement).
552
- *
553
- * ENG-2867 — but it must say the one thing that DOES change the verdict, and say it first.
554
- * The image bakes the latest Stable build at image build time and Cursor ships about
555
- * weekly, so the usual mismatch is a candidate one release behind, and updating is what
556
- * clears it. The ENG-2332 cut never said so. A candidate who had just been told to quit
557
- * Cursor read that as the remedy and re-ran the same refused command four times. So the
558
- * order is: what to do, that retrying without it will not help, the exact command to run
559
- * after, and only then why. The "why" is one sentence; it used to be the whole notice.
560
- *
561
- * Update is not guaranteed to land on the baked build: an image built before the newest
562
- * release is refused again after updating, since `seedVerdict` wants an exact commit. So
563
- * the copy says "usually", names that outcome as step 4, and the routes that need no
564
- * update follow it (Greptile on #3033). Step 4 diagnoses nothing: the check compares
565
- * commits, and a second refusal has more causes (an older image, a second install found
566
- * first) than a notice can tell apart. The routes below always work, so step 4 sends the
567
- * candidate there and states only the one fact the version line proves.
537
+ * ENG-2332 — the refusal when the candidate's editor is a different build from the one
538
+ * this workspace has.
539
+ *
540
+ * ENG-2884: no editor sets `requiresSeedMatch` any more, so this is reachable only if one
541
+ * is measured failing again and the flag goes back on. Kept short for that day: what
542
+ * happened, that updating usually clears it, and the routes that need no update. There is
543
+ * deliberately no "open it anyway".
568
544
  */
569
- export function editorSeedMismatchLines(ed, verdict, browserIdeUrl, alias, reentry = CURSOR_REENTRY_FALLBACK) {
570
- const short = (c) => c.slice(0, 12);
571
- const width = Math.max(`Your ${ed.label}:`.length, "This workspace:".length) + 1;
572
- const lines = [
573
- "",
574
- chalk.yellow.bold(` ⚠ Update ${ed.label}, then run this again. Your ${ed.label} is a different release`),
575
- chalk.yellow.bold(` from the one this workspace was built for, so it cannot open it.`),
576
- "",
577
- ` ${`Your ${ed.label}:`.padEnd(width)}${verdict.clientVersion ?? "unknown version"} (${short(verdict.clientCommit)})`,
578
- ` ${"This workspace:".padEnd(width)}${short(verdict.seedCommit)}`,
579
- "",
580
- chalk.bold(` Running this again, or quitting ${ed.label}, will not fix it. Updating usually does:`),
581
- "",
582
- ` 1. Update ${ed.label}: use "Check for Updates" in ${ed.label}'s menu, or download it from`,
583
- ` ${chalk.cyan(ed.downloadUrl)}`,
584
- ` 2. Quit ${ed.label} completely (Cmd+Q on macOS; File → Exit on Windows/Linux).`,
585
- ` 3. From a terminal that is NOT inside ${ed.label}, run:`,
586
- ` ${chalk.cyan(`${reentry.command} --ide ${ed.ide}`)}`,
587
- " 4. If it is refused again, use one of the routes below. (If \"Your " + ed.label + "\" still",
588
- ` shows the old version, this command is reading a different ${ed.label} install than`,
589
- " the one you updated.)",
590
- "",
591
- chalk.dim(` (Why: ${ed.label} needs a matching server inside the workspace, and the workspace cannot`),
592
- chalk.dim(" download one. A mismatched window looks normal, but your work in it is not recorded, so"),
593
- chalk.dim(" nothing was opened. Your workspace is up and waiting, and nothing is lost.)"),
594
- "",
595
- ` Refused after updating, or short on time? Work in the same workspace, fully recorded:`,
596
- "",
545
+ export function editorSeedMismatchLines(ed, verdict, pageUrl, alias) {
546
+ const which = verdict.clientVersion ? `${ed.label} ${verdict.clientVersion}` : `This ${ed.label}`;
547
+ return [
548
+ chalk.yellow.bold(`⚠ ${which} can't open this workspace.`),
549
+ ` Try updating ${ed.label} (${chalk.cyan(ed.downloadUrl)}), then ${SAME_COMMAND} again.`,
550
+ workNowLine(alias, pageUrl),
597
551
  ];
598
- if (browserIdeUrl) {
599
- lines.push(` Browser IDE: ${chalk.cyan(browserIdeUrl)}`);
600
- }
601
- else {
602
- lines.push(" Browser IDE: open your assessment page and use the browser IDE there");
603
- }
604
- if (ed.ide !== "vscode") {
605
- lines.push(` VS Code: ${chalk.cyan(`${reentry.command} --ide vscode`)}`);
606
- }
607
- lines.push(` Terminal: ${chalk.cyan(`ssh ${alias}`)}`);
608
- lines.push("");
609
- return lines;
610
552
  }
611
553
  /**
612
554
  * ENG-2332 — what to say when the editor CLI cannot be found anywhere.
613
555
  *
614
- * The line this replaces offered `<bin> --remote ssh-remote+<alias> …` as the
615
- * remediation for `<bin>` not existing. Everything here is something the candidate can do
616
- * WITHOUT that command: the editor's own Remote-SSH picker (the alias is already in their
617
- * `~/.ssh/config`), the palette command that would put the CLI on PATH, and the browser
618
- * IDE, which needs nothing installed at all.
556
+ * Never `<bin> --remote …`: that is the binary whose absence is the message. Everything
557
+ * here works without it: the editor's own Remote-SSH picker (the alias is already in
558
+ * their `~/.ssh/config`), the palette command that puts the CLI on PATH, and the routes
559
+ * that need no editor.
619
560
  */
620
- export function editorNotFoundLines(ed, alias, browserIdeUrl) {
621
- const lines = [
622
- "",
623
- chalk.yellow.bold(` ⚠ Couldn't find ${ed.label}'s \`${ed.bin}\` command on this machine.`),
624
- "",
625
- ` It is not on your PATH and it is not where ${ed.label}'s installer puts it, so`,
626
- " nothing has been opened. Your workspace is up and waiting — nothing is lost.",
627
- "",
628
- ` Your SSH config is already written, so from inside ${ed.label} you can connect`,
629
- " with no command line at all:",
630
- "",
631
- ` Command Palette → ${chalk.bold("Remote-SSH: Connect to Host…")} → ${chalk.bold(alias)}`,
632
- "",
633
- ` (Needs the "${ed.remoteSshExt}" extension, which ${ed.label} will offer to install.)`,
634
- "",
635
- ` To get the \`${ed.bin}\` command for next time: in ${ed.label}, Command Palette →`,
636
- ` "${ed.shellCommand}", then open a NEW terminal.`,
561
+ export function editorNotFoundLines(ed, alias, pageUrl) {
562
+ return [
563
+ chalk.yellow.bold(`⚠ Couldn't find ${ed.label}'s \`${ed.bin}\` command, so nothing was opened.`),
564
+ // Greptile on #3071: this route never restarts Cursor, and Cursor only loads the
565
+ // workspace's prompt recording when the app starts (gotcha 090).
566
+ ...(ed.ide === "cursor" ? [" Quit Cursor completely and reopen it first, or your AI prompts won't be recorded."] : []),
567
+ ` In ${ed.label}: Command Palette → ${chalk.bold("Remote-SSH: Connect to Host…")} → ${chalk.bold(alias)}`,
568
+ ` To get the \`${ed.bin}\` command for next time: Command Palette → "${ed.shellCommand}".`,
569
+ workNowLine(alias, pageUrl),
637
570
  ];
638
- if (browserIdeUrl) {
639
- lines.push("");
640
- lines.push(" Or skip the editor entirely — the browser IDE needs nothing installed:");
641
- lines.push(` ${chalk.cyan(browserIdeUrl)}`);
642
- }
643
- lines.push("");
644
- return lines;
645
571
  }
646
572
  /**
647
- * ENG-2332 — the positive signal, which the CLI had none of.
648
- *
649
- * It states exactly what was verified and not one step more. A server running in the
650
- * workspace means the window is editing the container's files, so the file, terminal and
651
- * commit lanes are live — that is what "recorded" may claim here.
652
- *
653
- * IT MAY NOT CLAIM AI CAPTURE ON CURSOR, and that is not hedging. Cursor reads its hooks
654
- * configuration once, at application startup (ENG-1728, measured): a candidate who
655
- * connected from an already-running Cursor has a live, correct, never-loaded hook, and
656
- * their prompts are not recorded although everything else is. The verification above
657
- * cannot see that — a server is up either way — so a flat "your work is being recorded"
658
- * would be the same false reassurance this ticket is about, moved one lane along. The
659
- * full notice is printed at selection time; this restates only the dependency.
573
+ * ENG-2332 — the footer under the verified success line.
574
+ *
575
+ * ENG-2890: for Cursor, one line naming the window that IS connected, since a restart
576
+ * can leave the candidate's other Cursor windows reopening beside it. The window title
577
+ * is safe to name here and only here: this prints after `verifyEditorAttached` saw a
578
+ * server, unlike the failure branches, where the never-attached window carries the same
579
+ * `[SSH: <alias>]` (ENG-2154). The workspace path is for whoever is debugging, so it is
580
+ * `--verbose` only. VS Code needs no footer.
660
581
  */
661
- export function editorConnectedLines(ed, alias) {
662
- const lines = [chalk.dim(` Workspace: ${alias}:${REMOTE_WORKSPACE_DIR}`)];
582
+ export function editorConnectedLines(ed, alias, verbose = false, platform = process.platform) {
583
+ const lines = [];
584
+ if (verbose)
585
+ lines.push(chalk.dim(` Workspace: ${alias}:${REMOTE_WORKSPACE_DIR}`));
663
586
  if (ed.ide === "cursor") {
664
- lines.push(chalk.dim(" Your AI prompts are recorded only if you quit Cursor completely before this opened;"));
665
- lines.push(chalk.dim(" reloading the window or reconnecting does not load the hook."));
587
+ const mod = platform === "darwin" ? "Cmd" : "Ctrl";
588
+ lines.push(` Use the Cursor window titled "workspace [SSH: ${alias}]". ${mod}+Shift+E shows your files.`);
666
589
  }
667
590
  return lines;
668
591
  }
669
592
  /**
670
- * ENG-2332 — what to say when a window opened and no server came up in the workspace.
671
- *
672
- * This is the copy the ticket was opened about. It used to be
673
- * "If a new window doesn't appear, run litmus connect again" — which is wrong twice: a
674
- * window DOES appear in this failure, and re-running connect cannot change the state,
675
- * which is exactly what had the reporter run it four times. So it names what was
676
- * observed, and every route it offers is one this failure does not block.
677
- *
678
- * `seed` rides along because on VS Code a mismatch is not a refusal (see
679
- * `requiresSeedMatch`) — so when a VS Code launch does fail, the build difference is the
680
- * first thing worth knowing, and it is already in hand.
681
- *
682
- * WHAT IT MAY NOT SAY IS THAT NOTHING WILL CHANGE. That is what it said first, and it is
683
- * a claim about the future made on a reading that stops at the verification budget. A VS
684
- * Code client whose build the image did not bake is NOT refused (`requiresSeedMatch`), so
685
- * its launch legitimately goes on to fetch a server through the mirror — a ~28.6 MB CLI
686
- * and, on a prewarm miss, the whole bundle — and a slow link can still be fetching when
687
- * the budget runs out. Telling that candidate their window is dead and pointing them
688
- * elsewhere is the harm `unverified` exists to avoid, arriving on the branch below it.
689
- * So it states the bound it actually waited (derived from `VERIFY_SCHEDULE_MS`, never a
690
- * second hard-coded copy), says the window may yet attach, and offers the other two
691
- * routes rather than instructing an exit. What it still may not do is tell them to re-run
692
- * this command, which is the original bug.
693
- *
694
- * AND IT MAY NOT HAND THEM A CHECK THAT PASSES ON THE DEAD WINDOW. It pointed at the
695
- * remote indicator — "the bottom-left corner reads SSH: <alias>" — and this repo has
696
- * measured that indicator PRESENT in exactly the failure case: `lib/editor-server.ts`'s
697
- * header records the never-attached window carrying `"remoteAuthority":
698
- * "ssh-remote+<alias>"` and being indistinguishable from a working one, and
699
- * `lib/session-end.ts` records (ENG-2154) that a window whose transport is gone still
700
- * reads `[SSH: <host>]`. A candidate told to look at the corner sees what they were told
701
- * to look for and goes on working unrecorded, which is this ticket's own bug re-entering
702
- * through its remediation. The honest sentence is that the two windows look the same —
703
- * that is WHY the CLI checks — so the copy says so and names no visual test at all.
593
+ * ENG-2332 — a window opened and no server came up in the workspace, so that window is
594
+ * running against the candidate's own machine and records nothing.
595
+ *
596
+ * ENG-2890: four lines. Close the window, retry with the cause that usually explains it
597
+ * (an editor that stopped to ask for a sign-in or an update), or work elsewhere now.
598
+ *
599
+ * IT MAY NOT HAND THEM A CHECK THAT PASSES ON THE DEAD WINDOW. The remote indicator
600
+ * ("SSH: <alias>" in the corner) is PRESENT on the never-attached window
601
+ * (`lib/editor-server.ts`'s header; ENG-2154 in `lib/session-end.ts`), so no visual test
602
+ * is named here at all.
704
603
  */
705
- export function editorNotConnectedLines(ed, alias, browserIdeUrl, seed, launchLog) {
706
- const waited = Math.round(VERIFY_SCHEDULE_MS[VERIFY_SCHEDULE_MS.length - 1] / 1000);
707
- const lines = [
708
- "",
709
- chalk.yellow.bold(` ⚠ ${ed.label} opened, but it never started a server in your workspace.`),
710
- "",
711
- ` A ${ed.label} window is on screen and it is NOT attached to your workspace: it is`,
712
- " running against your own machine. Anything you do in that window is work on your",
713
- chalk.yellow(" laptop, and it is not recorded."),
714
- "",
715
- ` We watched your workspace for ${waited} seconds after opening it and nothing`,
716
- ` started there. A first-ever ${ed.label} connection can still be downloading its`,
717
- " server at that point and attach a little later. There is nothing you can check",
718
- " by eye: an attached window and an unattached one look exactly the same, which is",
719
- " why this command checks your workspace instead of asking you to.",
720
- "",
604
+ export function editorNotConnectedLines(ed, alias, pageUrl, verbose = false) {
605
+ return [
606
+ // Greptile on #3071: a first download of this build's server can outlast the check, so
607
+ // the window may still attach; it is not called dead.
608
+ ` It may still be setting up. If the ${ed.label} window doesn't show your workspace files in a couple of minutes, close it: work in it isn't recorded.`,
609
+ ` Then ${SAME_COMMAND} again. If ${ed.label} asked you to sign in or update, that's usually why.`,
610
+ workNowLine(alias, pageUrl),
611
+ ...detailsLine(verbose),
721
612
  ];
722
- if (seed.kind === "mismatch") {
723
- lines.push(` The likely reason: your ${ed.label} is build ${seed.clientCommit.slice(0, 12)} and this`);
724
- lines.push(` workspace holds ${seed.seedCommit.slice(0, 12)}. It could not fetch a matching one.`);
725
- lines.push("");
726
- }
727
- if (browserIdeUrl) {
728
- lines.push(" You can work in the browser IDE instead — same workspace, same files, fully");
729
- lines.push(" recorded, and nothing to install:");
730
- lines.push(` ${chalk.cyan(browserIdeUrl)}`);
731
- }
732
- else {
733
- lines.push(" You can work in the browser IDE instead, from your assessment page — same");
734
- lines.push(" workspace, same files, fully recorded.");
735
- }
736
- lines.push("");
737
- lines.push(" You can also work from a terminal, which does not need the editor at all:");
738
- lines.push(` ${chalk.cyan(`ssh ${alias}`)}`);
739
- if (launchLog) {
740
- lines.push("");
741
- lines.push(chalk.dim(` ${ed.label} said, while trying to open:`));
742
- for (const line of launchLog.split(/\r?\n/).slice(-8))
743
- lines.push(chalk.dim(` ${line}`));
744
- }
745
- lines.push("");
746
- return lines;
747
613
  }
748
614
  /**
749
- * ENG-2332 — what to say when the editor never started at all.
750
- *
751
- * THIS BRANCH EXISTS BECAUSE THE ONE ABOVE MUST NOT BE REACHABLE WITHOUT A WINDOW.
752
- * `editorNotConnectedLines` opens by stating, as a fact, that a window is on screen and
753
- * running against the candidate's own machine; when `spawn` itself fails that sentence is
754
- * false, and the reading the verification produces is identical either way (no server,
755
- * empty launch log). So the spawn error is recorded (`EditorLaunchHandle`) and routed
756
- * here instead, where the only claim made is the one we can support.
757
- *
758
- * It offers the same two routes and, like every other failure branch, offers rather than
759
- * instructs: there is no window to close, and re-running this command is the remediation
760
- * the ticket was opened about. The error text is carried verbatim because it is the whole
761
- * of what we know — `ENOENT` on a resolved path means the app moved or was removed since
762
- * the `--version` probe a moment earlier, which is a thing the candidate can act on and
763
- * we cannot diagnose for them.
615
+ * ENG-2332 — the editor never started at all, so no window exists.
616
+ *
617
+ * Its own branch because `editorNotConnectedLines` tells them to close a window, which
618
+ * is false when `spawn` failed. The error is carried verbatim: it is all we know.
764
619
  */
765
- export function editorLaunchFailedLines(ed, alias, browserIdeUrl, error, launchLog) {
766
- const lines = [
767
- "",
768
- chalk.yellow.bold(` ⚠ ${ed.label} could not be started, so no window was opened.`),
769
- "",
770
- ` We found the ${ed.label} command and it answered a moment ago, but running it`,
771
- " failed:",
772
- ` ${chalk.dim(error)}`,
773
- "",
774
- ` That usually means ${ed.label} was moved, removed or updated between those two`,
775
- " moments. Nothing has been opened and nothing is being recorded yet.",
776
- "",
777
- ];
778
- if (browserIdeUrl) {
779
- lines.push(" You can work in the browser IDE instead — same workspace, same files, fully");
780
- lines.push(" recorded, and nothing to install:");
781
- lines.push(` ${chalk.cyan(browserIdeUrl)}`);
782
- }
783
- else {
784
- lines.push(" You can work in the browser IDE instead, from your assessment page — same");
785
- lines.push(" workspace, same files, fully recorded.");
786
- }
787
- lines.push("");
788
- lines.push(" You can also work from a terminal, which does not need the editor at all:");
789
- lines.push(` ${chalk.cyan(`ssh ${alias}`)}`);
790
- if (launchLog) {
791
- lines.push("");
792
- lines.push(chalk.dim(` ${ed.label} said, while trying to open:`));
793
- for (const line of launchLog.split(/\r?\n/).slice(-8))
794
- lines.push(chalk.dim(` ${line}`));
795
- }
796
- lines.push("");
797
- return lines;
620
+ export function editorLaunchFailedLines(ed, alias, pageUrl, error, verbose = false) {
621
+ return [` ${chalk.dim(error)}`, workNowLine(alias, pageUrl), ...detailsLine(verbose)];
798
622
  }
799
623
  /**
800
- * ENG-2332 — what to say when we could not reach the workspace to ask at all.
801
- *
802
- * Three-valued on purpose: "we could not check" is not "nothing came up", and saying
803
- * either of the other two here would be a claim we cannot make. It lives beside the other
804
- * copy rather than inline in `runConnect` so that what it may and may not say is testable
805
- * — it is bound by the same rule as `editorNotConnectedLines` and broke it the same way.
806
- *
807
- * It may NOT tell the candidate to look at the remote indicator. That indicator is
808
- * rendered from the remote authority, which the never-attached window carries (see
809
- * `lib/editor-server.ts`'s header, and ENG-2154 in `lib/session-end.ts`), so the check
810
- * passes in exactly the case it was offered to detect. It states the ambiguity instead
811
- * and offers the two routes that are not in doubt.
624
+ * ENG-2332 — we could not reach the workspace to ask, which is not "nothing came up".
625
+ *
626
+ * It may NOT offer the remote indicator as a check (ENG-2154: the never-attached window
627
+ * carries it). The file explorer is a fair one: a window not attached to the workspace
628
+ * has no workspace files to list.
812
629
  */
813
- export function editorUnverifiedLines(ed, alias, browserIdeUrl) {
814
- const lines = [
815
- ` Your ${ed.label} window may or may not be attached to your workspace, and there is`,
816
- " nothing you can check by eye: an attached window and an unattached one look exactly",
817
- ` the same. An unattached one runs on your own machine and is ${chalk.yellow("not recorded")}.`,
818
- "",
630
+ export function editorUnverifiedLines(ed, alias, pageUrl) {
631
+ return [
632
+ ` If the ${ed.label} window shows your workspace files in its file explorer, you're all set.`,
633
+ " If it doesn't, close that window.",
634
+ workNowLine(alias, pageUrl),
819
635
  ];
820
- if (browserIdeUrl) {
821
- lines.push(" You can work in the browser IDE instead — same workspace, same files, fully");
822
- lines.push(" recorded, and nothing to install:");
823
- lines.push(` ${chalk.cyan(browserIdeUrl)}`);
824
- }
825
- else {
826
- lines.push(" You can work in the browser IDE instead, from your assessment page — same");
827
- lines.push(" workspace, same files, fully recorded.");
828
- }
829
- lines.push("");
830
- lines.push(" You can also work from a terminal, which does not need the editor at all:");
831
- lines.push(` ${chalk.cyan(`ssh ${alias}`)}`);
832
- return lines;
833
636
  }
834
637
  /**
835
638
  * Ensure the Remote-SSH extension is installed in the user's editor before we
@@ -976,10 +779,8 @@ export function launchEditor(ed, command, alias, logPath) {
976
779
  return { launchFailure: () => failure };
977
780
  }
978
781
  /**
979
- * Point at the editor's own output. `namePath` is for the branch where a human is
980
- * walking a candidate through a failure: there the file is named whether or not
981
- * `--verbose` was asked for, since `editorLaunchLogPath` gives it a per-run suffix that
982
- * nobody can guess unaided.
782
+ * Point at the editor's own output, under `--verbose` only (ENG-2890: the failure copy
783
+ * says "Details: add --verbose." instead of printing a path by default).
983
784
  *
984
785
  * A PATH IS NAMED ONLY WHEN THERE IS SOMETHING AT IT, and the reason is that the file
985
786
  * routinely does not exist. `launchEditor` falls back to `stdio: "ignore"` whenever
@@ -989,15 +790,14 @@ export function launchEditor(ed, command, alias, logPath) {
989
790
  * Handing a support person a path with nothing behind it reads as the CLI having lost
990
791
  * the log, which is a worse answer than saying plainly that the editor wrote nothing.
991
792
  */
992
- export function editorLaunchOutputLines(logPath, launchLog, verbose, namePath) {
993
- if (!namePath && !verbose)
793
+ export function editorLaunchOutputLines(logPath, launchLog, verbose) {
794
+ if (!verbose)
994
795
  return [];
995
796
  if (!launchLog)
996
797
  return [chalk.dim(" The editor wrote no output while opening.")];
997
798
  const lines = [chalk.dim(` Editor output: ${logPath}`)];
998
- if (verbose)
999
- for (const line of launchLog.split(/\r?\n/))
1000
- lines.push(chalk.dim(` ${line}`));
799
+ for (const line of launchLog.split(/\r?\n/))
800
+ lines.push(chalk.dim(` ${line}`));
1001
801
  return lines;
1002
802
  }
1003
803
  /** What the editor wrote while trying to open, or null when it said nothing. */
@@ -1023,8 +823,8 @@ function sshProbeRunner(alias) {
1023
823
  }
1024
824
  };
1025
825
  }
1026
- function probeWorkspace(ed, run) {
1027
- return parseWorkspaceProbe(run(remoteProbeCommand(ed.serverHomeDir, ed.seedCommitPath)));
826
+ function probeWorkspace(ed, run, clientCommit = null) {
827
+ return parseWorkspaceProbe(run(remoteProbeCommand(ed.serverHomeDir, ed.seedCommitPath, clientCommit)));
1028
828
  }
1029
829
  /**
1030
830
  * ENG-2332 — poll the workspace until a server appears, or the budget runs out.
@@ -1052,7 +852,7 @@ function probeWorkspace(ed, run) {
1052
852
  * than taken whole.
1053
853
  */
1054
854
  const SPAWN_ERROR_POLL_MS = 200;
1055
- export async function verifyEditorAttached(ed, baseline, run, onWait, launched) {
855
+ export async function verifyEditorAttached(ed, baseline, run, onWait, launched, clientCommit = null) {
1056
856
  const started = Date.now();
1057
857
  const budget = VERIFY_SCHEDULE_MS[VERIFY_SCHEDULE_MS.length - 1];
1058
858
  let sawNotConnected = false;
@@ -1074,7 +874,7 @@ export async function verifyEditorAttached(ed, baseline, run, onWait, launched)
1074
874
  if (launched.launchFailure() !== null)
1075
875
  return { kind: "unverified" };
1076
876
  onWait(Math.round((Date.now() - started) / 1000));
1077
- const current = probeWorkspace(ed, run);
877
+ const current = probeWorkspace(ed, run, clientCommit);
1078
878
  const verdict = verifyVerdict(baseline, current);
1079
879
  if (verdict.kind === "connected")
1080
880
  return verdict;
@@ -1112,20 +912,15 @@ export async function attemptEditorLaunch(ed, io) {
1112
912
  * (piped / CI), so scripted runs never block on the prompt.
1113
913
  *
1114
914
  * `log` is injectable ONLY so that the Cursor notice below is testable through the real
1115
- * selection path rather than by reading a string constant. A warning that is never
1116
- * reached is precisely the bug this ticket is fixing, so a test that cannot see whether
1117
- * it was printed is not a test of it.
1118
- *
1119
- * `reentry` is the way back in, and it is threaded from `runConnect` rather than
1120
- * rebuilt here because only the caller holds the token and the resolved base. When it
1121
- * is absent (a test, a scripted call) the notice degrades to the placeholder form
1122
- * rather than dropping the route — see `cursorReentryLines`.
915
+ * selection path rather than by reading a string constant, and `platform` so that both
916
+ * of its forms are (see `cursorSelectionNoticeLines`).
1123
917
  */
1124
- export async function resolveIde(flag, log = console.log, reentry) {
918
+ export async function resolveIde(flag, log = console.log, platform = process.platform) {
1125
919
  const norm = (flag ?? "").trim().toLowerCase();
1126
920
  if (norm === "vscode" || norm === "cursor" || norm === "browser" || norm === "ssh") {
1127
921
  if (norm === "cursor")
1128
- announceCursorFullQuit(log, reentry);
922
+ for (const line of cursorSelectionNoticeLines(platform))
923
+ log(line);
1129
924
  return { ide: norm, chosen: true };
1130
925
  }
1131
926
  if (norm)
@@ -1134,10 +929,10 @@ export async function resolveIde(flag, log = console.log, reentry) {
1134
929
  return { ide: "vscode", chosen: false };
1135
930
  const answered = await promptIdeAnswer();
1136
931
  const ide = ideForChoice(answered);
1137
- // At SELECTION time — not after the ~1-2 minute boot wait, and not trailing the
1138
- // "opening Cursor" line where it scrolls past. See `announceCursorFullQuit`.
932
+ // At SELECTION time — not after the ~1-2 minute boot wait, where it scrolls past.
1139
933
  if (ide === "cursor")
1140
- announceCursorFullQuit(log, reentry);
934
+ for (const line of cursorSelectionNoticeLines(platform))
935
+ log(line);
1141
936
  return { ide, chosen: answered.trim() !== "" };
1142
937
  }
1143
938
  // How long `litmus connect` waits for a started workspace to become reachable.
@@ -1185,99 +980,105 @@ export function keepWaitingForWorkspace(elapsedMs, status) {
1185
980
  export function candidatePageUrl(apiBase, assessmentId) {
1186
981
  return `${apiBase}/candidate/${assessmentId}`;
1187
982
  }
1188
- /** The placeholder form, for a call that does not know the token (tests, scripted runs). */
1189
- const CURSOR_REENTRY_FALLBACK = { command: "litmus connect <token>", pageUrl: null };
1190
983
  /**
1191
- * ENG-2145 — how to get back in, said in the same breath as "quit".
1192
- *
1193
- * Elena, 2026-09-04, after two people who know the product could not work it out:
1194
- * "it prompts you to quit cursor and come back in but it's not quite intuitive how
1195
- * we're going to get back in."
1196
- *
1197
- * The notice used to offer exactly one route — "let this command reopen it" — and that
1198
- * is the one route the instruction above it destroys. A candidate told to quit Cursor is
1199
- * very likely reading the instruction IN CURSOR'S OWN INTEGRATED TERMINAL, because that
1200
- * is where they have a shell; `Cmd+Q` then takes `litmus connect` with it. What is left
1201
- * on screen is a quit editor, no running command, and nothing anywhere naming the way
1202
- * back. On the clock, that reads as the workspace being gone.
1203
- *
1204
- * So the route is stated as an instruction rather than as a promise about a process that
1205
- * may no longer exist, and the terminal it must be run from is named: a candidate who
1206
- * has just been told to quit an application cannot be told to use that application's
1207
- * terminal. The assessment page comes second and is the one that survives closing the
1208
- * tab AND losing the terminal, which is the state Elena was actually in.
1209
- *
1210
- * Both routes reopen the SAME workspace — `litmus connect` re-registers this machine's
1211
- * key and wakes a parked container through the ENG-2005 machinery, and the portal's own
1212
- * card does the same — so the reassurance is a fact about the platform, not a hope.
984
+ * ENG-1950 — the one thing a Cursor candidate has to be told when they pick Cursor.
985
+ *
986
+ * Cursor reads its prompt-recording setup once, at application startup (ENG-1728,
987
+ * measured), so a Cursor that is already open when the workspace opens records no AI
988
+ * prompts while everything else works. ENG-2890: on macOS connect quits and reopens
989
+ * Cursor itself (`lib/cursor-restart.ts`), so nothing is said here and the restart step
990
+ * speaks when it acts. Elsewhere a graceful quit cannot be confirmed, so the candidate is
991
+ * asked to do it, in two lines: the 23-line notice this replaced was the wall the
992
+ * ENG-2890 candidate read past. The second line exists because quitting Cursor also
993
+ * closes its integrated terminal, and with it this command (ENG-2145).
1213
994
  */
1214
- export function cursorReentryLines(reentry = CURSOR_REENTRY_FALLBACK) {
1215
- const lines = [
1216
- " To come back in after quitting, open a terminal that is NOT inside Cursor",
1217
- " (Terminal or iTerm on macOS; PowerShell or Windows Terminal on Windows) and run:",
1218
- "",
1219
- ` ${chalk.cyan(reentry.command)}`,
1220
- "",
1221
- " That reopens this same workspace, with your work exactly as you left it.",
995
+ export function cursorSelectionNoticeLines(platform = process.platform) {
996
+ if (platformSupportsGracefulCursorRestart(platform))
997
+ return [];
998
+ return [
999
+ " Before Cursor opens, quit it completely (File → Exit), or your AI prompts won't be recorded.",
1000
+ " If this terminal is inside Cursor, run the same litmus connect command from another terminal.",
1222
1001
  ];
1223
- // Deliberately NOT "use the button there". The portal's one-click reopen is the
1224
- // browser-IDE link, and `browser_ide_url` is null wherever `WORKSPACE_IDE_DOMAIN`
1225
- // is unset — those candidates get the same command in a copy field instead. Naming
1226
- // a control a candidate then cannot find costs them the trust they need in the rest
1227
- // of this notice, which is the same line `workspace-guidance.ts` draws over the tool
1228
- // list. What is true on every deployment is that the page states the route.
1229
- if (reentry.pageUrl) {
1230
- lines.push(" Or open your assessment page, which shows the way back in:");
1231
- lines.push(` ${chalk.cyan(reentry.pageUrl)}`);
1232
- }
1233
- else {
1234
- lines.push(" Your assessment page shows the way back in too.");
1235
- }
1236
- return lines;
1237
1002
  }
1238
1003
  /**
1239
- * ENG-1950 — the one thing a Cursor candidate has to be told, and the only place we can
1240
- * reliably tell them.
1241
- *
1242
- * Cursor's hooks service reads its configuration ONCE, at application startup. Not on a
1243
- * window reload, not on a fresh Remote-SSH connection. Our prompt capture for Cursor is
1244
- * such a hook (staged into the container by `litmus-firstboot` §5b, ENG-1728), so a
1245
- * candidate who connects from an already-running Cursor gets a session where the hook is
1246
- * present, correct, and never loaded.
1247
- *
1248
- * That failure is entirely silent. The editor connects, the workspace works, files save,
1249
- * the AI answers — and nothing is recorded, so their Process dimension is graded on an
1250
- * empty record. It cost about an hour of investigation on 2026-08-28 to diagnose from
1251
- * the inside, with the container in hand; a candidate has no chance of noticing at all.
1252
- *
1253
- * So the copy is written for a candidate, not for us: what to do, and what it costs if
1254
- * they don't. No "hooks", no "capture lane". The mechanism is our problem.
1255
- *
1256
- * ENG-2145 adds the second half of that sentence: what to do is now "quit, THEN run
1257
- * this", because an instruction to quit with no stated way back is what sent two people
1258
- * who know the product looking for their workspace. The order is deliberate — the cost
1259
- * of skipping the quit still comes last, so the thing a candidate acts on immediately is
1260
- * the thing they read first, and `cursorReentryLines` sits between them rather than
1261
- * after the whole notice where it would scroll.
1004
+ * ENG-2890 — is this process running in Cursor's integrated terminal?
1005
+ *
1006
+ * Quitting Cursor kills its terminals, so the macOS auto-restart would kill this CLI
1007
+ * between the quit and the relaunch, leaving no editor and no command. `TERM_PROGRAM`
1008
+ * is "vscode" in VS Code AND in Cursor's terminal, so one of Cursor's own markers is
1009
+ * required as well: its trace id, its ToDesktop bundle id, or its bundled askpass node.
1010
+ * VS Code's terminal (bundle id `com.microsoft.VSCode`, no trace id) is not a match.
1262
1011
  */
1263
- export function announceCursorFullQuit(log = console.log, reentry = CURSOR_REENTRY_FALLBACK) {
1264
- log("");
1265
- log(chalk.yellow.bold(" ⚠ Quit Cursor completely first — otherwise your work is not recorded."));
1266
- log("");
1267
- log(" If Cursor is open right now, quit the whole app:");
1268
- log(` macOS: ${chalk.bold("Cmd+Q")}`);
1269
- log(` Windows/Linux: ${chalk.bold("File → Exit")}, or close every Cursor window`);
1270
- log("");
1271
- for (const line of cursorReentryLines(reentry))
1272
- log(line);
1273
- log("");
1274
- log(" (If you are running this from a terminal outside Cursor, it is still running");
1275
- log(" and will open Cursor for you once your workspace is ready.)");
1276
- log("");
1277
- log(" Reloading the window or reconnecting is NOT enough — Cursor only picks up your");
1278
- log(" workspace's recording setup when the app itself starts. If it stays open,");
1279
- log(" everything will look normal and none of your work will be recorded.");
1280
- log("");
1012
+ export function runningInsideCursorTerminal(env = process.env) {
1013
+ if (env.TERM_PROGRAM !== "vscode")
1014
+ return false;
1015
+ return (Boolean(env.CURSOR_TRACE_ID) ||
1016
+ (env.__CFBundleIdentifier ?? "").includes("todesktop") ||
1017
+ (env.VSCODE_GIT_ASKPASS_NODE ?? "").includes("Cursor"));
1018
+ }
1019
+ /** ENG-2890 — said instead of quitting Cursor out from under this terminal. */
1020
+ export function insideCursorTerminalLines() {
1021
+ return [
1022
+ chalk.yellow.bold("⚠ This terminal is inside Cursor, and restarting Cursor would close it."),
1023
+ " Run the same litmus connect command from Terminal or iTerm instead.",
1024
+ ];
1025
+ }
1026
+ /** ENG-2890 — the one line connect prints before its macOS auto-restart of Cursor. */
1027
+ /** Cursor success when this command could not confirm a fresh start (gotcha 090). */
1028
+ export const CURSOR_RECORDING_CAVEAT_LINE = " Your AI prompts are recorded only if Cursor was fully quit before it opened. If it wasn't, quit it and run the same litmus connect command again.";
1029
+ /** How long a freshly started Cursor gets to finish restoring its session (ENG-2884). */
1030
+ export const CURSOR_SETTLE_MS = 6000;
1031
+ /** When, into the attach wait, the open request is sent to Cursor a second time. */
1032
+ export const CURSOR_RESEND_AFTER_S = 20;
1033
+ /** Whether to send the workspace-open request again (once, Cursor only, after a fresh start). */
1034
+ export function shouldResendCursorOpen(ide, startedFresh, waitedSeconds, resend) {
1035
+ return ide === "cursor" && startedFresh && !resend.done && waitedSeconds >= CURSOR_RESEND_AFTER_S;
1036
+ }
1037
+ /**
1038
+ * ENG-2884 — the launch when the open request may be sent a second time. Every `send`
1039
+ * answers ONE handle for all the requests so far. It reports a failure only once EVERY
1040
+ * request has failed: a failed retry must not end the wait while the first request may
1041
+ * still be attaching (Greptile on #3078). A retry's failure is kept in `readLog` instead,
1042
+ * beside every request's output, so `--verbose` still shows why it failed.
1043
+ */
1044
+ export function resendableLaunch(launch) {
1045
+ const sent = [];
1046
+ const handle = {
1047
+ launchFailure: () => {
1048
+ const failures = sent.map((s) => s.handle.launchFailure());
1049
+ return failures.length > 0 && failures.every((f) => f !== null) ? failures[failures.length - 1] : null;
1050
+ },
1051
+ };
1052
+ return {
1053
+ send: (logPath) => {
1054
+ sent.push({ logPath, handle: launch(logPath) });
1055
+ return handle;
1056
+ },
1057
+ readLog: (read) => {
1058
+ const logs = [];
1059
+ sent.forEach((s, i) => {
1060
+ const log = read(s.logPath);
1061
+ if (log !== null)
1062
+ logs.push(log);
1063
+ const failure = i > 0 ? s.handle.launchFailure() : null;
1064
+ if (failure !== null)
1065
+ logs.push(`Second open request failed: ${failure}`);
1066
+ });
1067
+ return logs.length > 0 ? logs.join("\n") : null;
1068
+ },
1069
+ };
1070
+ }
1071
+ export const CURSOR_RESTARTING_LINE = "Restarting Cursor so your AI prompts are recorded (it reopens on its own)...";
1072
+ /**
1073
+ * ENG-2890 — the quit was asked for and Cursor is still running, so nothing is
1074
+ * launched (see the gate in `connectFlow`). `renderCursorRestartOutcome`'s longer form
1075
+ * stays with init and doctor.
1076
+ */
1077
+ export function cursorQuitUnconfirmedLines() {
1078
+ return [
1079
+ chalk.red.bold("✖ Cursor didn't quit. It may be waiting on a \"Save changes?\" dialog."),
1080
+ " Quit Cursor yourself, then run the same litmus connect command again from Terminal or iTerm.",
1081
+ ];
1281
1082
  }
1282
1083
  /**
1283
1084
  * Map a menu answer to an editor. (ENG-1667 item 3; Cursor added by ENG-1950.)
@@ -1316,7 +1117,7 @@ export function ideMenuLines() {
1316
1117
  ` ${chalk.bold("2")}) VS Code — native, via Remote-SSH`,
1317
1118
  // ENG-2389. Cursor sits beside VS Code, where it belongs by kinship, and plain SSH is
1318
1119
  // last — see `ideForChoice` for what that renumbering costs and why it is bounded.
1319
- ` ${chalk.bold("3")}) Cursor — native, via Remote-SSH ${chalk.dim("(quit Cursor first)")}`,
1120
+ ` ${chalk.bold("3")}) Cursor — native, via Remote-SSH`,
1320
1121
  ` ${chalk.bold("4")}) SSH — just print the command for your terminal`,
1321
1122
  ];
1322
1123
  }
@@ -1403,24 +1204,33 @@ function promptIdeAnswer() {
1403
1204
  });
1404
1205
  });
1405
1206
  }
1406
- /** Open a URL in the user's default browser. Best-effort, cross-platform. */
1207
+ /**
1208
+ * Open a URL in the user's default browser. Best-effort, cross-platform. Resolves
1209
+ * whether the opener started, so the caller prints its fallback in order rather than
1210
+ * after the success line, and never prints the URL itself: the browser-IDE URL carries
1211
+ * the `t=` credential (ENG-2890).
1212
+ */
1407
1213
  function openBrowser(url) {
1408
1214
  const spec = process.platform === "darwin" ? ["open", [url]]
1409
1215
  : process.platform === "win32" ? ["cmd", ["/c", "start", "", url]]
1410
1216
  : ["xdg-open", [url]];
1411
- const fallback = () => console.log(`Open this in your browser:\n ${url}`);
1412
- try {
1413
- const child = spawn(spec[0], spec[1], { detached: true, stdio: "ignore" });
1414
- // spawn reports a missing opener ASYNCHRONOUSLY via an 'error' event, not a
1415
- // sync throw — without this listener an ENOENT (e.g. no xdg-open on a minimal
1416
- // Linux / WSL / headless box) becomes an uncaughtException that crashes the
1417
- // CLI right after the success line. The try/catch alone can't see it.
1418
- child.on("error", fallback);
1419
- child.unref();
1420
- }
1421
- catch {
1422
- fallback();
1423
- }
1217
+ return new Promise((resolve) => {
1218
+ try {
1219
+ const child = spawn(spec[0], spec[1], { detached: true, stdio: "ignore" });
1220
+ // spawn reports a missing opener ASYNCHRONOUSLY via an 'error' event, not a
1221
+ // sync throw — without this listener an ENOENT (e.g. no xdg-open on a minimal
1222
+ // Linux / WSL / headless box) becomes an uncaughtException that crashes the CLI.
1223
+ child.on("error", () => resolve(false));
1224
+ child.on("spawn", () => resolve(true));
1225
+ child.unref();
1226
+ // Never let the browser opener hold connect up: if neither event arrives, the
1227
+ // launch is assumed to have gone ahead, which is how connect behaved before ENG-2890.
1228
+ setTimeout(() => resolve(true), 3000).unref();
1229
+ }
1230
+ catch {
1231
+ resolve(false);
1232
+ }
1233
+ });
1424
1234
  }
1425
1235
  /**
1426
1236
  * Drop any stale known_hosts entries for this workspace's target + bastion.
@@ -1501,14 +1311,10 @@ async function connectFlow(token, ideOpt, opts, skillImport) {
1501
1311
  // Pick the editor up front (flag or interactive prompt) so the choice is made
1502
1312
  // before the boot wait, not after.
1503
1313
  //
1504
- // ENG-2145 — the Cursor notice is told the way back in. Both routes are known here
1505
- // and nowhere inside `resolveIde`: the token is a credential the caller holds, and
1506
- // `apiBase` is the frontend base the disclosure branch below already points at.
1507
- const reentry = {
1508
- command: `litmus connect ${token}`,
1509
- pageUrl: candidatePageUrl(apiBase, meta.assessmentId),
1510
- };
1511
- const { ide: chosenIde, chosen: ideWasChosen } = await resolveIde(ideOpt, console.log, reentry);
1314
+ // ENG-2890 — the assessment page is the one re-entry route connect prints: its URL
1315
+ // carries only the assessment id, never the token.
1316
+ const pageUrl = candidatePageUrl(apiBase, meta.assessmentId);
1317
+ const { ide: chosenIde, chosen: ideWasChosen } = await resolveIde(ideOpt, console.log);
1512
1318
  let ide = chosenIde;
1513
1319
  // ENG-2814: asked here, next to the editor choice, and uploaded once the workspace is
1514
1320
  // ready. Never fails the connect. See `lib/connect-skills.ts`.
@@ -1578,7 +1384,7 @@ async function connectFlow(token, ideOpt, opts, skillImport) {
1578
1384
  }
1579
1385
  boot.succeed("Workspace ready");
1580
1386
  if (pendingSkills)
1581
- skillImport.report = startSkillImport(backendUrl, token, pendingSkills);
1387
+ skillImport.report = startSkillImport(backendUrl, token, pendingSkills, undefined, () => ide);
1582
1388
  // Browser IDE: no local SSH / VS Code needed — just open the signed URL.
1583
1389
  if (ide === "browser") {
1584
1390
  // Greptile on #1713. The browser IDE is now the DEFAULT, so this branch is reached by
@@ -1596,14 +1402,20 @@ async function connectFlow(token, ideOpt, opts, skillImport) {
1596
1402
  ide = "vscode";
1597
1403
  }
1598
1404
  else if (fallback === "unavailable") {
1599
- fatal("The browser IDE isn't available for this workspace yet.", "Open it in native VS Code instead: `litmus connect <token> --ide vscode`.", { severity: "warning" });
1405
+ fatal("The browser IDE isn't available for this workspace yet.", "Open it in native VS Code instead: run the same `litmus connect` command with `--ide vscode`.", { severity: "warning" });
1600
1406
  return;
1601
1407
  }
1602
1408
  }
1603
1409
  if (ide === "browser" && conn.browser_ide_url) {
1604
- openBrowser(conn.browser_ide_url);
1605
- success("Opening your workspace in the browser");
1606
- console.log(chalk.dim(` ${conn.browser_ide_url}`));
1410
+ // ENG-2890 — the URL carries the `t=` credential, so it is opened, never printed,
1411
+ // except under --verbose for whoever is debugging the opener.
1412
+ // `openBrowser` resolving true means the opener started, not that a browser showed the
1413
+ // page (xdg-open can exit with no browser at all), so the fallback is always given.
1414
+ await openBrowser(conn.browser_ide_url);
1415
+ success("✔ Opening your workspace in the browser");
1416
+ console.log(` If nothing opens, use the browser IDE link on your assessment page: ${chalk.cyan(pageUrl)}`);
1417
+ if (verbose)
1418
+ console.log(chalk.dim(` ${conn.browser_ide_url}`));
1607
1419
  return;
1608
1420
  }
1609
1421
  if (!conn.ssh_command) {
@@ -1634,7 +1446,8 @@ async function connectFlow(token, ideOpt, opts, skillImport) {
1634
1446
  pruneStaleHostKeys(parsed);
1635
1447
  const block = buildSshConfigBlock(alias, KEY_PATH, parsed);
1636
1448
  const cfgPath = writeSshConfig(alias, block, buildControlPathBlock(alias, parsed));
1637
- info(`SSH config written to ${cfgPath} (Host "${alias}")`);
1449
+ if (verbose)
1450
+ info(`SSH config written to ${cfgPath} (Host "${alias}")`);
1638
1451
  // ENG-2693 — prove the connection before handing it off (see `sshPreflight`). The
1639
1452
  // client is the one the route will run, as in ENG-2682. A failure stops the route with
1640
1453
  // ssh's own words, rather than in an editor that fails the same way silently — and it
@@ -1745,8 +1558,8 @@ async function connectFlow(token, ideOpt, opts, skillImport) {
1745
1558
  // no-op that touches nothing.
1746
1559
  //
1747
1560
  // Cursor is still given no laptop-side equivalent of the WRITE: its AI is captured
1748
- // in-container, which is exactly what the full-quit notice at selection time
1749
- // protects.
1561
+ // in-container, which is what the Cursor restart (or, off macOS, the quit note at
1562
+ // selection time) protects.
1750
1563
  //
1751
1564
  // ENG-2152 — a MISMATCH refuses below and opens nothing, so it may not WRITE. See
1752
1565
  // `chatConfigActionForIdentity`, which is where that belongs: keying the write on
@@ -1791,7 +1604,7 @@ async function connectFlow(token, ideOpt, opts, skillImport) {
1791
1604
  // manual-instructions branch. Checking the mismatch first means a Windows candidate
1792
1605
  // whose `code` is Cursor is told so, rather than told their editor is not installed.
1793
1606
  if (identity.kind === "mismatch") {
1794
- for (const line of editorMismatchLines(editor, NATIVE_EDITORS[identity.actual], reentry)) {
1607
+ for (const line of editorMismatchLines(editor, NATIVE_EDITORS[identity.actual], alias, pageUrl)) {
1795
1608
  console.log(line);
1796
1609
  }
1797
1610
  return;
@@ -1800,22 +1613,13 @@ async function connectFlow(token, ideOpt, opts, skillImport) {
1800
1613
  // ENG-2332 — the CLI is not on PATH and not where the installer puts it. Everything
1801
1614
  // offered here is reachable WITHOUT it; the line this replaced offered
1802
1615
  // `<bin> --remote …`, i.e. the binary whose absence is the message.
1803
- for (const line of editorNotFoundLines(editor, alias, conn.browser_ide_url ?? null))
1616
+ for (const line of editorNotFoundLines(editor, alias, pageUrl))
1804
1617
  console.log(line);
1805
- // The full-quit requirement outlives this degraded path — arguably it matters MORE
1806
- // here, since a candidate connecting by hand is doing it from an editor they still
1807
- // have to quit.
1808
- //
1809
- // ENG-2145: and so does the way back. This used to point at "the note above", which
1810
- // is a back-reference to a notice printed BEFORE a boot wait that can run to two
1811
- // minutes and fills the screen — and, worse, one whose own route back was a promise
1812
- // about this very command, which by then has returned. So the route is restated
1813
- // here in full rather than referred to.
1814
- if (ide === "cursor") {
1815
- console.log(chalk.yellow(" Quit Cursor completely before you connect, or your work is not recorded."));
1816
- for (const line of cursorReentryLines(reentry))
1618
+ // A candidate connecting by hand does it from a Cursor they still have to quit, and
1619
+ // nothing here restarts it, so the selection note is restated (empty on macOS).
1620
+ if (ide === "cursor")
1621
+ for (const line of cursorSelectionNoticeLines())
1817
1622
  console.log(line);
1818
- }
1819
1623
  return;
1820
1624
  }
1821
1625
  if (identity.kind === "unverified") {
@@ -1828,9 +1632,10 @@ async function connectFlow(token, ideOpt, opts, skillImport) {
1828
1632
  // printing this first produced "opening it as you asked" immediately followed by
1829
1633
  // "not found on PATH" — two lines contradicting each other about whether anything was
1830
1634
  // about to open, which is on the ticket in its own right.
1831
- console.log(chalk.dim(` (Couldn't confirm that \`${editor.bin}\` is ${editor.label}; opening it as you asked.)`));
1635
+ if (verbose)
1636
+ console.log(chalk.dim(` (Couldn't confirm that \`${editor.bin}\` is ${editor.label}; opening it as you asked.)`));
1832
1637
  }
1833
- if (resolved.source === "install-location") {
1638
+ if (verbose && resolved.source === "install-location") {
1834
1639
  info(`Using ${editor.label} at ${resolved.command}`);
1835
1640
  console.log(chalk.dim(` (\`${editor.bin}\` isn't on your PATH. ${editor.label}'s "${editor.shellCommand}" adds it.)`));
1836
1641
  }
@@ -1841,9 +1646,9 @@ async function connectFlow(token, ideOpt, opts, skillImport) {
1841
1646
  const probe = sshProbeRunner(alias);
1842
1647
  // ENG-2491, Greptile P1 on #2613 — the baseline probe is taken HERE, before
1843
1648
  // any decision to quit Cursor, and the resulting `seedVerdict` is checked
1844
- // before quitting too. `attemptEditorLaunch` below can still REFUSE the
1845
- // launch on a Cursor build that doesn't match this workspace's seed
1846
- // (`requiresSeedMatch`), and a refusal opens nothing — so a quit sequenced
1649
+ // before quitting too. `attemptEditorLaunch` below REFUSES the launch on a
1650
+ // mismatched build for any editor with `requiresSeedMatch` set (none today,
1651
+ // ENG-2884), and a refusal opens nothing — so a quit sequenced
1847
1652
  // ahead of that check could close every Cursor window on a path that was
1848
1653
  // always going to end in "nothing was opened", with no way given back in
1849
1654
  // locally. Checking the identical rule first means a quit only ever
@@ -1854,20 +1659,22 @@ async function connectFlow(token, ideOpt, opts, skillImport) {
1854
1659
  // two, for the same reason this file already takes it once for the seed
1855
1660
  // check and the verification baseline together.
1856
1661
  const readSpinner = ora(`Checking your workspace...`).start();
1857
- const baseline = probeWorkspace(editor, probe);
1662
+ const baseline = probeWorkspace(editor, probe, resolved.version.commit);
1858
1663
  readSpinner.stop();
1859
1664
  const seed = seedVerdict(resolved.version, baseline);
1860
1665
  const seedWillRefuseLaunch = seed.kind === "mismatch" && editor.requiresSeedMatch;
1861
1666
  // ENG-2491 — Cursor reads its hooks configuration once, at its OWN launch,
1862
1667
  // no matter which remote window it then opens (ENG-1728, measured) — so an
1863
1668
  // ALREADY-RUNNING local Cursor never reloads even for a brand new
1864
- // Remote-SSH connection. `announceCursorFullQuit` (already said, at
1865
- // selection time, above) has told the candidate this for months; this acts
1866
- // on it instead of only saying it, with the same graceful, never-a-kill
1669
+ // Remote-SSH connection. This quits it with the same graceful, never-a-kill
1867
1670
  // quit `init` uses (see lib/cursor-restart.ts). The launch a few lines
1868
1671
  // below IS the relaunch — connect already opens the editor, so there is
1869
1672
  // nothing else to relaunch it into.
1870
1673
  //
1674
+ // ENG-2890 — NOT from Cursor's own terminal: the quit would kill this
1675
+ // process before the relaunch, leaving no editor and no command. That case
1676
+ // stops before anything is touched, the same way an unconfirmed quit does.
1677
+ //
1871
1678
  // NEVER proceeds to launch on an unconfirmed quit. Doing so would hand the
1872
1679
  // new `--remote` argv to the SAME still-running process — Cursor's
1873
1680
  // single-instance lock just forwards it as a new window into a hooks
@@ -1875,23 +1682,40 @@ async function connectFlow(token, ideOpt, opts, skillImport) {
1875
1682
  // failure, not a fix for it. So an unconfirmed quit stops here, the same
1876
1683
  // way every other refusal in this file does: nothing opened, workspace
1877
1684
  // still up and waiting, and the exact way back in.
1685
+ // Whether the Cursor about to open is a FRESH start, so its prompt recording loads:
1686
+ // it was not running, or this command confirmed it quit. Only then may success say the
1687
+ // work is recorded (Greptile on #3071); elsewhere (Linux, Windows, an unreadable process
1688
+ // list) the candidate was only asked to quit it, and nothing checked that they did.
1689
+ let cursorStartsFresh = false;
1878
1690
  if (!seedWillRefuseLaunch && ide === "cursor" && platformSupportsGracefulCursorRestart(process.platform)) {
1879
1691
  const cursorIo = realCursorProcessIo();
1880
- if (cursorIo.isRunning() === "running") {
1881
- for (const line of announceCursorAutoRestart())
1882
- console.log(line);
1692
+ const presence = cursorIo.isRunning();
1693
+ if (presence === "not-running")
1694
+ cursorStartsFresh = true;
1695
+ if (presence === "running") {
1696
+ if (runningInsideCursorTerminal()) {
1697
+ for (const line of insideCursorTerminalLines())
1698
+ console.log(line);
1699
+ return;
1700
+ }
1701
+ info(CURSOR_RESTARTING_LINE);
1883
1702
  const quit = await attemptQuit(cursorIo);
1884
1703
  if (!quit) {
1885
- for (const line of renderCursorRestartOutcome({ kind: "quit-unconfirmed" }))
1704
+ for (const line of cursorQuitUnconfirmedLines())
1886
1705
  console.log(line);
1887
- console.log();
1888
- console.log(chalk.yellow.bold(" Quit Cursor completely yourself, then run this again:"));
1889
- for (const line of cursorReentryLines(reentry))
1890
- console.log(line);
1891
- console.log();
1892
1706
  return;
1893
1707
  }
1894
- info("Cursor quit — reopening it for your workspace...");
1708
+ cursorStartsFresh = true;
1709
+ }
1710
+ // ENG-2884. A COLD start handed `--remote` loses it: measured twice on a real
1711
+ // profile (2026-10-01 21:11 and 2026-10-02 00:26 PT), Cursor 3 came up restoring its
1712
+ // previous session (the Agents window plus old folders) and never created the
1713
+ // workspace window. Sent to a Cursor that has finished starting, the same request
1714
+ // opens it (./cursorrelaunch variant C). So start Cursor first, let it settle, and
1715
+ // only then launch; if it cannot be started this way, the launch below is the cold
1716
+ // start it always was.
1717
+ if (cursorStartsFresh && (await attemptRelaunch(cursorIo))) {
1718
+ await cursorIo.sleep(CURSOR_SETTLE_MS);
1895
1719
  }
1896
1720
  }
1897
1721
  // A holder rather than a `let`, because the spinner is created inside a callback and
@@ -1901,6 +1725,8 @@ async function connectFlow(token, ideOpt, opts, skillImport) {
1901
1725
  // "Checking the Remote-SSH extension" through it names the wrong step, on the one path
1902
1726
  // where the next thing the candidate may see is a refusal that installed nothing.
1903
1727
  const spinners = { verify: null };
1728
+ const resend = { done: false };
1729
+ const launches = resendableLaunch((logPath) => launchEditor(editor, resolved.command, alias, logPath));
1904
1730
  const outcome = await attemptEditorLaunch(editor, {
1905
1731
  // Reused from the pre-check above — see the comment there for why this
1906
1732
  // is a cache, not a second probe.
@@ -1909,17 +1735,21 @@ async function connectFlow(token, ideOpt, opts, skillImport) {
1909
1735
  ensureExtension: () => {
1910
1736
  const extSpinner = ora(`Checking ${editor.label} Remote-SSH extension...`).start();
1911
1737
  const state = ensureRemoteSshExtension(editor, resolved.command);
1912
- if (state === "present")
1738
+ // ENG-2890: an internal step, so it says nothing when it worked.
1739
+ if (state === "failed") {
1740
+ extSpinner.warn(`Couldn't install the Remote-SSH extension (${editor.remoteSshExt}). Install it from ${editor.label}'s Extensions view if ${editor.label} asks.`);
1741
+ }
1742
+ else if (!verbose)
1743
+ extSpinner.stop();
1744
+ else if (state === "present")
1913
1745
  extSpinner.succeed("Remote-SSH extension ready");
1914
- else if (state === "installed")
1915
- extSpinner.succeed(`Installed the ${editor.label} Remote-SSH extension`);
1916
1746
  else
1917
- extSpinner.warn("Couldn't auto-install the Remote-SSH extension");
1747
+ extSpinner.succeed(`Installed the ${editor.label} Remote-SSH extension`);
1918
1748
  return state;
1919
1749
  },
1920
1750
  launchLogPath: editorLaunchLogPath,
1921
1751
  launch: (logPath) => {
1922
- const launched = launchEditor(editor, resolved.command, alias, logPath);
1752
+ const launched = launches.send(logPath);
1923
1753
  // The launch is not the result, so nothing claims one here. What used to be printed
1924
1754
  // at this point was `✔ Opening <editor> → <alias>`, which is true of a spawn that is
1925
1755
  // about to fail, and was the last thing the CLI said in a session where nothing was
@@ -1927,15 +1757,22 @@ async function connectFlow(token, ideOpt, opts, skillImport) {
1927
1757
  spinners.verify = ora(`Opening ${editor.label} — waiting for it to start in your workspace...`).start();
1928
1758
  return launched;
1929
1759
  },
1930
- readLaunchLog: readEditorLaunchLog,
1760
+ readLaunchLog: () => launches.readLog(readEditorLaunchLog),
1931
1761
  verifyAttached: (baseline, launched) => verifyEditorAttached(editor, baseline, probe, (waited) => {
1932
1762
  if (spinners.verify) {
1933
1763
  spinners.verify.text = `Opening ${editor.label} — waiting for it to start in your workspace... (${waited}s)`;
1934
1764
  }
1935
- }, launched),
1765
+ // ENG-2884: if the first request still produced no attached window, ask the
1766
+ // (now running) Cursor once more. A window already opening for this folder is
1767
+ // focused rather than duplicated, so a slow first attach costs nothing.
1768
+ if (shouldResendCursorOpen(editor.ide, cursorStartsFresh, waited, resend)) {
1769
+ resend.done = true;
1770
+ launches.send(editorLaunchLogPath());
1771
+ }
1772
+ }, launched, resolved.version.commit),
1936
1773
  });
1937
1774
  if (outcome.kind === "refused") {
1938
- for (const line of editorSeedMismatchLines(editor, outcome.seed, conn.browser_ide_url ?? null, alias, reentry)) {
1775
+ for (const line of editorSeedMismatchLines(editor, outcome.seed, pageUrl, alias)) {
1939
1776
  console.log(line);
1940
1777
  }
1941
1778
  return;
@@ -1945,49 +1782,45 @@ async function connectFlow(token, ideOpt, opts, skillImport) {
1945
1782
  // No window was created, so none of the not-connected copy is true here. See
1946
1783
  // `editorLaunchFailedLines`.
1947
1784
  launchSpinner?.fail(`Couldn't start ${editor.label}.`);
1948
- for (const line of editorLaunchFailedLines(editor, alias, conn.browser_ide_url ?? null, outcome.error, verbose ? null : outcome.launchLog)) {
1785
+ for (const line of editorLaunchFailedLines(editor, alias, pageUrl, outcome.error, verbose))
1949
1786
  console.log(line);
1950
- }
1951
- for (const line of editorLaunchOutputLines(outcome.logPath, outcome.launchLog, verbose, true))
1787
+ for (const line of editorLaunchOutputLines(outcome.logPath, outcome.launchLog, verbose))
1952
1788
  console.log(line);
1953
1789
  return;
1954
1790
  }
1955
1791
  if (outcome.verdict.kind === "connected") {
1956
- launchSpinner?.succeed(`${editor.label} is running in your workspace — your files, terminal and commits are recorded.`);
1957
- skillImport.footer.push(...editorConnectedLines(editor, alias));
1792
+ const recordingKnown = editor.ide !== "cursor" || cursorStartsFresh;
1793
+ launchSpinner?.succeed(recordingKnown
1794
+ ? `${editor.label} is open in your workspace. Your work is recorded.`
1795
+ : `${editor.label} is open in your workspace.`);
1796
+ if (!recordingKnown)
1797
+ skillImport.footer.push(CURSOR_RECORDING_CAVEAT_LINE);
1798
+ skillImport.footer.push(...editorConnectedLines(editor, alias, verbose));
1958
1799
  }
1959
1800
  else if (outcome.verdict.kind === "unverified") {
1960
1801
  // Three-valued on purpose: we could not reach the workspace to ask, which is not the
1961
1802
  // same as knowing nothing came up, and saying either of the other two here would be a
1962
1803
  // claim we cannot make. See `verifyVerdict`.
1963
- launchSpinner?.warn(`Couldn't check whether ${editor.label} attached to your workspace.`);
1964
- for (const line of editorUnverifiedLines(editor, alias, conn.browser_ide_url ?? null))
1804
+ launchSpinner?.warn(`Couldn't confirm ${editor.label} connected to your workspace.`);
1805
+ for (const line of editorUnverifiedLines(editor, alias, pageUrl))
1965
1806
  console.log(line);
1966
1807
  }
1967
1808
  else {
1968
- launchSpinner?.fail(`${editor.label} did not start in your workspace.`);
1969
- // Under --verbose the whole log is printed below, so the copy's own 8-line tail is
1970
- // withheld rather than repeated; without it that tail is the only view of the output.
1971
- for (const line of editorNotConnectedLines(editor, alias, conn.browser_ide_url ?? null, outcome.seed, verbose ? null : outcome.launchLog)) {
1809
+ launchSpinner?.fail(`${editor.label} hasn't connected to your workspace yet.`);
1810
+ for (const line of editorNotConnectedLines(editor, alias, pageUrl, verbose))
1972
1811
  console.log(line);
1973
- }
1974
- if (outcome.extension === "failed") {
1812
+ // Under --verbose: the log path and the editor's whole output. The extension failure
1813
+ // was already warned about when it happened.
1814
+ if (verbose && outcome.extension === "failed") {
1975
1815
  console.log(chalk.dim(` The Remote-SSH extension ("${editor.remoteSshExt}") also failed to install, which would explain this.`));
1976
1816
  }
1977
- // The one outcome somebody is walked through, and the log now carries a per-run
1978
- // suffix — so its PATH is named here whether or not --verbose was asked for.
1979
- for (const line of editorLaunchOutputLines(outcome.logPath, outcome.launchLog, verbose, true))
1817
+ for (const line of editorLaunchOutputLines(outcome.logPath, outcome.launchLog, verbose))
1980
1818
  console.log(line);
1981
1819
  return;
1982
1820
  }
1983
- // The log's PATH is named on every branch a human gets walked through, and `unverified`
1984
- // is one: it is precisely the branch where we could not reach the workspace at all, so
1985
- // the editor's own resolver message is the only evidence anybody has — and the name
1986
- // carries a per-run suffix nobody can guess unaided. `editorLaunchOutputLines` still
1987
- // withholds a path with nothing behind it.
1988
- for (const line of editorLaunchOutputLines(outcome.logPath, outcome.launchLog, verbose, outcome.verdict.kind !== "connected")) {
1821
+ // Under --verbose, the editor's own output on both remaining branches (ENG-2890).
1822
+ for (const line of editorLaunchOutputLines(outcome.logPath, outcome.launchLog, verbose))
1989
1823
  console.log(line);
1990
- }
1991
1824
  // ENG-2154. The window we have just opened outlives the session, and what it
1992
1825
  // does when the session ends looks like a failure. This is the only moment
1993
1826
  // anything of ours is on the candidate's own machine — `connect` returns here
@@ -1995,6 +1828,6 @@ async function connectFlow(token, ideOpt, opts, skillImport) {
1995
1828
  // assessment page — so it is also the only warning that reaches a candidate
1996
1829
  // who ends their session somewhere we do not run. See lib/session-end.ts,
1997
1830
  // which also carries why we do not simply close the window.
1998
- skillImport.footer.push(...editorEndingForewarning(editor.label));
1831
+ skillImport.footer.push(...editorEndingForewarning());
1999
1832
  }
2000
1833
  //# sourceMappingURL=connect.js.map