litmus-cli 1.4.66 → 1.4.68

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.
@@ -1,12 +1,14 @@
1
+ import { ConnectionAttempt } from "../lib/connection-attempt.js";
2
+ import { updateSshConfig } from "../lib/ssh-config-file.js";
1
3
  import os from "os";
2
4
  import path from "path";
3
5
  import readline from "readline";
4
- import { closeSync, constants, existsSync, mkdirSync, openSync, readFileSync, writeFileSync } from "fs";
6
+ import { closeSync, constants, existsSync, mkdirSync, openSync, readFileSync } from "fs";
5
7
  import { randomBytes } from "crypto";
6
8
  import { execFileSync, spawn, spawnSync } from "child_process";
7
9
  import chalk from "chalk";
8
10
  import ora from "ora";
9
- import { fetchInitMetadata, startWorkspace, getConnection, fetchEditorChatConfig, ServerError, hintForError, isNetworkError } from "../lib/api.js";
11
+ import { fetchInitMetadata, startWorkspace, getConnection, fetchEditorChatConfig, ServerError, hintForError, isNetworkError, workspaceStartNetworkDetail } from "../lib/api.js";
10
12
  import { applyEditorChatConfig } from "../lib/chat-model-config.js";
11
13
  import { finishSkillImport, offerSkillImport, startSkillImport } from "../lib/connect-skills.js";
12
14
  import { resolveApiBase } from "../lib/api-base.js";
@@ -16,7 +18,7 @@ import { parseEditorVersion, parseWorkspaceProbe, remoteProbeCommand, seedVerdic
16
18
  import { fatal, holdExitFor, success, info, warn, setErrorContext, reportEvent } from "../lib/errors.js";
17
19
  import { oldWindowsSshHint, openSshSession, parseOpenSshVersion, proxyJumpBroken, readEditorSettings, readSshVersionBanner, SSH_CONNECTION_FAILED, sshExecutableFor, sshPreflight, sshPreflightHint, } from "../lib/ssh-client.js";
18
20
  import { editorEndingForewarning } from "../lib/session-end.js";
19
- import { attemptQuit, attemptRelaunch, platformSupportsGracefulCursorRestart, realCursorProcessIo, } from "../lib/cursor-restart.js";
21
+ import { attemptRelaunch, platformSupportsGracefulCursorRestart, realCursorProcessIo, } from "../lib/cursor-restart.js";
20
22
  /**
21
23
  * `litmus connect <token>` — the CLI connector for the v2 codespace, modeled on
22
24
  * `gh codespace ssh/code`. It removes the manual "paste your public key + copy
@@ -320,13 +322,9 @@ function ensureSshKey(keyPath) {
320
322
  stdio: "ignore",
321
323
  });
322
324
  }
323
- function writeSshConfig(alias, block, controlBlock) {
325
+ async function writeSshConfig(alias, block, controlBlock) {
324
326
  const cfgPath = path.join(os.homedir(), ".ssh", "config");
325
- const dir = path.dirname(cfgPath);
326
- if (!existsSync(dir))
327
- mkdirSync(dir, { recursive: true, mode: 0o700 });
328
- const existing = existsSync(cfgPath) ? readFileSync(cfgPath, "utf8") : "";
329
- writeFileSync(cfgPath, upsertSshConfigContent(existing, alias, block, controlBlock), { mode: 0o600 });
327
+ await updateSshConfig(cfgPath, (existing) => upsertSshConfigContent(existing, alias, block, controlBlock));
330
328
  return cfgPath;
331
329
  }
332
330
  /**
@@ -412,7 +410,7 @@ function probeEditorCli(command) {
412
410
  return null;
413
411
  }
414
412
  }
415
- function resolveEditorCommand(ed) {
413
+ export function resolveEditorCommand(ed) {
416
414
  const onPath = probeEditorCli(ed.bin);
417
415
  if (onPath)
418
416
  return { command: ed.bin, source: "path", version: onPath };
@@ -599,9 +597,6 @@ export function editorSeedMismatchLines(ed, verdict, pageUrl, alias) {
599
597
  export function editorNotFoundLines(ed, alias, pageUrl) {
600
598
  return [
601
599
  chalk.yellow.bold(`⚠ Couldn't find ${ed.label}'s \`${ed.bin}\` command, so nothing was opened.`),
602
- // Greptile on #3071: this route never restarts Cursor, and Cursor only loads the
603
- // workspace's prompt recording when the app starts (gotcha 090).
604
- ...(ed.ide === "cursor" ? [" Quit Cursor completely and reopen it first, or your AI prompts won't be recorded."] : []),
605
600
  ` In ${ed.label}: Command Palette → ${chalk.bold("Remote-SSH: Connect to Host…")} → ${chalk.bold(alias)}`,
606
601
  ` To get the \`${ed.bin}\` command for next time: Command Palette → "${ed.shellCommand}".`,
607
602
  workNowLine(alias, pageUrl),
@@ -690,7 +685,7 @@ export function editorUnverifiedLines(ed, alias, pageUrl) {
690
685
  * null skips the conflict check rather than stopping connect: the check only refuses on
691
686
  * evidence, never on a failed read.
692
687
  */
693
- function listEditorExtensions(command) {
688
+ export function listEditorExtensions(command) {
694
689
  const list = editorSpawnSpec(command, ["--list-extensions"], process.platform);
695
690
  if (!list)
696
691
  return null;
@@ -702,22 +697,13 @@ function listEditorExtensions(command) {
702
697
  return null;
703
698
  }
704
699
  }
705
- function ensureRemoteSshExtension(ed, command) {
706
- const list = editorSpawnSpec(command, ["--list-extensions"], process.platform);
707
- // Undrivable on this platform — the same outcome as an install that would not run.
708
- // `failed` is already best-effort and never aborts connect.
709
- if (!list)
700
+ export function ensureRemoteSshExtension(ed, command, listed) {
701
+ // An unreadable inventory is not evidence of absence; don't mutate it blindly.
702
+ if (listed === null)
710
703
  return "failed";
711
- try {
712
- const listed = spawnSync(list.command, list.args, { encoding: "utf8", shell: list.shell, timeout: 60000 });
713
- if (!listed.error && (listed.stdout ?? "").split(/\r?\n/).some((l) => l.trim().toLowerCase() === ed.remoteSshExt)) {
714
- return "present";
715
- }
716
- }
717
- catch {
718
- // Couldn't list (unexpected) — fall through and try to install anyway.
719
- }
720
- const install = editorSpawnSpec(command, ["--install-extension", ed.remoteSshExt, "--force"], process.platform);
704
+ if (listed.split(/\r?\n/).some((l) => l.trim().toLowerCase() === ed.remoteSshExt))
705
+ return "present";
706
+ const install = editorSpawnSpec(command, ["--install-extension", ed.remoteSshExt], process.platform);
721
707
  if (!install)
722
708
  return "failed";
723
709
  try {
@@ -871,11 +857,11 @@ function readEditorLaunchLog(logPath) {
871
857
  return null;
872
858
  }
873
859
  }
874
- function sshProbeRunner(alias) {
860
+ export function sshProbeRunner(alias, executable = "ssh") {
875
861
  return (remoteCommand) => {
876
862
  try {
877
- const probed = spawnSync("ssh", ["-o", "BatchMode=yes", "-o", "ConnectTimeout=10", "-o", "LogLevel=ERROR", alias, remoteCommand], { encoding: "utf8", timeout: 20000 });
878
- if (probed.error)
863
+ const probed = spawnSync(executable, ["-o", "BatchMode=yes", "-o", "ConnectTimeout=10", "-o", "LogLevel=ERROR", alias, remoteCommand], { encoding: "utf8", timeout: 20000 });
864
+ if (probed.error || probed.status !== 0)
879
865
  return null;
880
866
  return probed.stdout ?? null;
881
867
  }
@@ -1043,52 +1029,10 @@ export function keepWaitingForWorkspace(elapsedMs, status) {
1043
1029
  export function candidatePageUrl(apiBase, assessmentId) {
1044
1030
  return `${apiBase}/candidate/${assessmentId}`;
1045
1031
  }
1046
- /**
1047
- * ENG-1950 — the one thing a Cursor candidate has to be told when they pick Cursor.
1048
- *
1049
- * Cursor reads its prompt-recording setup once, at application startup (ENG-1728,
1050
- * measured), so a Cursor that is already open when the workspace opens records no AI
1051
- * prompts while everything else works. ENG-2890: on macOS connect quits and reopens
1052
- * Cursor itself (`lib/cursor-restart.ts`), so nothing is said here and the restart step
1053
- * speaks when it acts. Elsewhere a graceful quit cannot be confirmed, so the candidate is
1054
- * asked to do it, in two lines: the 23-line notice this replaced was the wall the
1055
- * ENG-2890 candidate read past. The second line exists because quitting Cursor also
1056
- * closes its integrated terminal, and with it this command (ENG-2145).
1057
- */
1058
- export function cursorSelectionNoticeLines(platform = process.platform) {
1059
- if (platformSupportsGracefulCursorRestart(platform))
1060
- return [];
1061
- return [
1062
- " Before Cursor opens, quit it completely (File → Exit), or your AI prompts won't be recorded.",
1063
- " If this terminal is inside Cursor, run the same litmus connect command from another terminal.",
1064
- ];
1065
- }
1066
- /**
1067
- * ENG-2890 — is this process running in Cursor's integrated terminal?
1068
- *
1069
- * Quitting Cursor kills its terminals, so the macOS auto-restart would kill this CLI
1070
- * between the quit and the relaunch, leaving no editor and no command. `TERM_PROGRAM`
1071
- * is "vscode" in VS Code AND in Cursor's terminal, so one of Cursor's own markers is
1072
- * required as well: its trace id, its ToDesktop bundle id, or its bundled askpass node.
1073
- * VS Code's terminal (bundle id `com.microsoft.VSCode`, no trace id) is not a match.
1074
- */
1075
- export function runningInsideCursorTerminal(env = process.env) {
1076
- if (env.TERM_PROGRAM !== "vscode")
1077
- return false;
1078
- return (Boolean(env.CURSOR_TRACE_ID) ||
1079
- (env.__CFBundleIdentifier ?? "").includes("todesktop") ||
1080
- (env.VSCODE_GIT_ASKPASS_NODE ?? "").includes("Cursor"));
1081
- }
1082
- /** ENG-2890 — said instead of quitting Cursor out from under this terminal. */
1083
- export function insideCursorTerminalLines() {
1084
- return [
1085
- chalk.yellow.bold("⚠ This terminal is inside Cursor, and restarting Cursor would close it."),
1086
- " Run the same litmus connect command from Terminal or iTerm instead.",
1087
- ];
1032
+ /** Remote hooks initialize in the remote window; no desktop restart is required (ENG-3176). */
1033
+ export function cursorSelectionNoticeLines(_platform = process.platform) {
1034
+ return [];
1088
1035
  }
1089
- /** ENG-2890 — the one line connect prints before its macOS auto-restart of Cursor. */
1090
- /** Cursor success when this command could not confirm a fresh start (gotcha 090). */
1091
- export const CURSOR_RECORDING_CAVEAT_LINE = " If Cursor was open before this, quit it and run the same litmus connect command again, or your AI prompts won't be recorded.";
1092
1036
  /** How long a freshly started Cursor gets to finish restoring its session (ENG-2884). */
1093
1037
  export const CURSOR_SETTLE_MS = 6000;
1094
1038
  /** When, into the attach wait, the open request is sent to Cursor a second time. */
@@ -1131,18 +1075,6 @@ export function resendableLaunch(launch) {
1131
1075
  },
1132
1076
  };
1133
1077
  }
1134
- export const CURSOR_RESTARTING_LINE = "Restarting Cursor so your AI prompts are recorded (it reopens on its own)...";
1135
- /**
1136
- * ENG-2890 — the quit was asked for and Cursor is still running, so nothing is
1137
- * launched (see the gate in `connectFlow`). `renderCursorRestartOutcome`'s longer form
1138
- * stays with init and doctor.
1139
- */
1140
- export function cursorQuitUnconfirmedLines() {
1141
- return [
1142
- chalk.red.bold("✖ Cursor didn't quit. It may be waiting on a \"Save changes?\" dialog."),
1143
- " Quit Cursor yourself, then run the same litmus connect command again from Terminal or iTerm.",
1144
- ];
1145
- }
1146
1078
  /**
1147
1079
  * Map a menu answer to an editor. (ENG-1667 item 3; Cursor added by ENG-1950.)
1148
1080
  *
@@ -1309,8 +1241,7 @@ function pruneStaleHostKeys(p) {
1309
1241
  // known_hosts as [host]:port, so a bare-host -R would miss it.
1310
1242
  if (p.port)
1311
1243
  targets.push(`[${p.host}]:${p.port}`);
1312
- if (p.jumpHost)
1313
- targets.push(p.jumpPort ? `[${p.jumpHost}]:${p.jumpPort}` : p.jumpHost);
1244
+ // The shared bastion is not reprovisioned with this workspace. Preserve its pinned key.
1314
1245
  for (const t of targets) {
1315
1246
  try {
1316
1247
  execFileSync("ssh-keygen", ["-R", t], { stdio: "ignore" });
@@ -1337,18 +1268,34 @@ export async function runConnect(token, ideOpt, opts = {}) {
1337
1268
  // printed last, on every way out of the flow, so it neither delays the editor nor
1338
1269
  // is lost when a later step fails. The workspace lines follow it, so the candidate's
1339
1270
  // last read is where they are and what the session ending looks like.
1271
+ setErrorContext({ token, apiBase: resolveApiBase(process.env.LITMUS_API_URL) });
1272
+ const attempt = new ConnectionAttempt();
1340
1273
  const skillImport = { report: null, footer: [], sshAlias: null, publishedPorts: [] };
1274
+ // Register before entering the flow: a fast fatal report can settle before the
1275
+ // caller's finally begins, especially while an async SSH config write is unwinding.
1276
+ let releaseExitHold;
1277
+ holdExitFor(new Promise((resolve) => { releaseExitHold = resolve; }));
1341
1278
  try {
1342
- await connectFlow(token, ideOpt, opts, skillImport);
1279
+ await connectFlow(token, ideOpt, opts, skillImport, attempt);
1280
+ }
1281
+ catch (error) {
1282
+ attempt.finish("failed");
1283
+ attempt.evidence(error instanceof Error ? error.message : null);
1284
+ throw error;
1343
1285
  }
1344
1286
  finally {
1345
- // Held, so a `fatal` in the flow waits for the upload and its report (ENG-2919).
1346
- await holdExitFor((async () => {
1287
+ try {
1288
+ await attempt.flush();
1289
+ if (opts.verbose || attempt.needsSupport)
1290
+ console.log(chalk.dim(` Connection ID: ${attempt.id}`));
1347
1291
  if (skillImport.report)
1348
1292
  await finishSkillImport(skillImport.report);
1349
1293
  for (const line of skillImport.footer)
1350
1294
  console.log(line);
1351
- })());
1295
+ }
1296
+ finally {
1297
+ releaseExitHold();
1298
+ }
1352
1299
  }
1353
1300
  // The ssh route's session runs last, so nothing we print lands inside the remote shell.
1354
1301
  if (!skillImport.sshAlias)
@@ -1380,7 +1327,7 @@ async function openSshRoute(alias) {
1380
1327
  if (status)
1381
1328
  process.exitCode = status;
1382
1329
  }
1383
- async function connectFlow(token, ideOpt, opts, skillImport) {
1330
+ async function connectFlow(token, ideOpt, opts, skillImport, attempt) {
1384
1331
  const verbose = Boolean(opts.verbose);
1385
1332
  const apiBase = resolveApiBase(process.env.LITMUS_API_URL);
1386
1333
  setErrorContext({ token, apiBase });
@@ -1395,6 +1342,8 @@ async function connectFlow(token, ideOpt, opts, skillImport) {
1395
1342
  spinner.succeed(meta.assessmentName);
1396
1343
  }
1397
1344
  catch (err) {
1345
+ attempt.evidence(workspaceStartNetworkDetail(err));
1346
+ attempt.evidence(err instanceof Error ? err.message : null);
1398
1347
  // ENG-2895: the Error line below says what failed; a second "Connection failed" line
1399
1348
  // above it was the same fact twice, and wrong when the cause was a rejected token.
1400
1349
  spinner.stop();
@@ -1417,28 +1366,37 @@ async function connectFlow(token, ideOpt, opts, skillImport) {
1417
1366
  // ENG-2890 — the assessment page is the one re-entry route connect prints: its URL
1418
1367
  // carries only the assessment id, never the token.
1419
1368
  const pageUrl = candidatePageUrl(apiBase, meta.assessmentId);
1369
+ attempt.stage("selection");
1420
1370
  const { ide: chosenIde, chosen: ideWasChosen } = await resolveIde(ideOpt, console.log);
1421
1371
  let ide = chosenIde;
1372
+ attempt.editor(ide);
1422
1373
  // ENG-2814: asked here, next to the editor choice, and uploaded once the workspace is
1423
1374
  // ready. Never fails the connect. See `lib/connect-skills.ts`.
1375
+ attempt.stage("skills");
1424
1376
  const pendingSkills = await offerSkillImport({
1425
1377
  backendUrl, token, flag: opts.skills, verbose,
1426
1378
  });
1379
+ attempt.stage("ssh_key");
1427
1380
  // 2. Ensure a local SSH key (private key never leaves this machine).
1428
1381
  try {
1429
1382
  ensureSshKey(KEY_PATH);
1430
1383
  }
1431
1384
  catch (err) {
1385
+ attempt.evidence(workspaceStartNetworkDetail(err));
1386
+ attempt.evidence(err instanceof Error ? err.message : null);
1432
1387
  fatal("Couldn't create an SSH key.", "Install OpenSSH (it includes ssh-keygen), then run the same litmus connect command again.", { internalDetail: err instanceof Error ? err.message : String(err), severity: "warning" });
1433
1388
  return;
1434
1389
  }
1435
1390
  const pubkey = readFileSync(`${KEY_PATH}.pub`, "utf8").trim();
1436
1391
  // 3. Start the workspace with our public key.
1392
+ attempt.stage("workspace_start");
1437
1393
  const boot = ora("Starting your workspace...").start();
1438
1394
  try {
1439
1395
  await startWorkspace(backendUrl, token, pubkey);
1440
1396
  }
1441
1397
  catch (err) {
1398
+ attempt.evidence(workspaceStartNetworkDetail(err));
1399
+ attempt.evidence(err instanceof Error ? err.message : null);
1442
1400
  // The Error line below names what went wrong; see the sign-in spinner above.
1443
1401
  boot.stop();
1444
1402
  const code = err instanceof ServerError ? err.errorCode : undefined;
@@ -1452,6 +1410,10 @@ async function connectFlow(token, ideOpt, opts, skillImport) {
1452
1410
  fatal("This assessment is closed. Its deadline has passed.", "Contact the company if you need an extension.", { severity: "warning" });
1453
1411
  return;
1454
1412
  }
1413
+ if (isNetworkError(err)) {
1414
+ fatal("Couldn't reach Litmus to start your workspace.", "Check your internet connection, then run the same litmus connect command again. If it keeps happening, contact the company.", { internalDetail: workspaceStartNetworkDetail(err), severity: "error" });
1415
+ return;
1416
+ }
1455
1417
  const msg = err instanceof Error ? err.message : String(err);
1456
1418
  fatal(msg, "Run the same litmus connect command again in a moment. If it keeps happening, contact the company.", {
1457
1419
  severity: err instanceof ServerError && err.statusCode && err.statusCode < 500 ? "warning" : "error",
@@ -1490,6 +1452,8 @@ async function connectFlow(token, ideOpt, opts, skillImport) {
1490
1452
  return;
1491
1453
  }
1492
1454
  boot.succeed("Workspace ready");
1455
+ attempt.stage("workspace_ready");
1456
+ attempt.checkpoint();
1493
1457
  if (pendingSkills)
1494
1458
  skillImport.report = holdExitFor(startSkillImport(backendUrl, token, pendingSkills, undefined, () => ide));
1495
1459
  // Browser IDE: no local SSH / VS Code needed — just open the signed URL.
@@ -1507,6 +1471,7 @@ async function connectFlow(token, ideOpt, opts, skillImport) {
1507
1471
  if (fallback === "vscode") {
1508
1472
  warn("The browser IDE isn't available here. Opening VS Code instead.");
1509
1473
  ide = "vscode";
1474
+ attempt.editor(ide);
1510
1475
  }
1511
1476
  else if (fallback === "unavailable") {
1512
1477
  fatal("The browser IDE isn't available for this workspace yet.", "Open it in VS Code instead: run the same litmus connect command with --ide vscode.", { severity: "warning" });
@@ -1514,6 +1479,8 @@ async function connectFlow(token, ideOpt, opts, skillImport) {
1514
1479
  }
1515
1480
  }
1516
1481
  if (ide === "browser" && conn.browser_ide_url) {
1482
+ attempt.stage("browser_open");
1483
+ attempt.finish("browser_requested");
1517
1484
  // ENG-3137 — the workspace now trusts this machine's key, so the assessment page's
1518
1485
  // Plain SSH offers `ssh <alias>`. Write the entry that alias names, as every other
1519
1486
  // route does. Best-effort: a config we could not write must not cost the browser route.
@@ -1522,11 +1489,13 @@ async function connectFlow(token, ideOpt, opts, skillImport) {
1522
1489
  const parsed = parseSshCommand(conn.ssh_command);
1523
1490
  const alias = aliasFor(conn.ssh_hostname, token);
1524
1491
  pruneStaleHostKeys(parsed);
1525
- const cfgPath = writeSshConfig(alias, buildSshConfigBlock(alias, KEY_PATH, parsed), buildControlPathBlock(alias, parsed));
1492
+ const cfgPath = await writeSshConfig(alias, buildSshConfigBlock(alias, KEY_PATH, parsed), buildControlPathBlock(alias, parsed));
1526
1493
  if (verbose)
1527
1494
  info(`SSH config written to ${cfgPath} (Host "${alias}")`);
1528
1495
  }
1529
1496
  catch (err) {
1497
+ attempt.evidence(workspaceStartNetworkDetail(err));
1498
+ attempt.evidence(err instanceof Error ? err.message : null);
1530
1499
  if (verbose)
1531
1500
  info(`SSH config not written: ${err instanceof Error ? err.message : String(err)}`);
1532
1501
  }
@@ -1552,6 +1521,13 @@ async function connectFlow(token, ideOpt, opts, skillImport) {
1552
1521
  // Prune stale host keys first so a re-provisioned container's new key is
1553
1522
  // accepted, not refused (ENG-1221).
1554
1523
  const parsed = parseSshCommand(conn.ssh_command);
1524
+ // Reuse this observation across compatibility checks, preflight and diagnostics.
1525
+ const sshBanners = new Map();
1526
+ const sshBanner = (executable) => {
1527
+ if (!sshBanners.has(executable))
1528
+ sshBanners.set(executable, readSshVersionBanner(executable));
1529
+ return sshBanners.get(executable) ?? null;
1530
+ };
1555
1531
  // ENG-2682 — Windows' own OpenSSH below 8.1 cannot run the ProxyJump every bastion
1556
1532
  // route depends on (see `lib/ssh-client.ts`). Refuse before writing anything, with the
1557
1533
  // version named and a way out, rather than hand an editor a connection it will lose
@@ -1559,17 +1535,18 @@ async function connectFlow(token, ideOpt, opts, skillImport) {
1559
1535
  // `remote.SSH.path` when it names one, PATH's ssh otherwise.
1560
1536
  if (process.platform === "win32" && parsed.jumpHost && ide !== "browser") {
1561
1537
  const executable = sshExecutableFor(ide, ide === "ssh" ? null : readEditorSettings(ide));
1562
- const client = parseOpenSshVersion(readSshVersionBanner(executable) ?? "");
1538
+ const client = parseOpenSshVersion(sshBanner(executable) ?? "");
1563
1539
  if (client && proxyJumpBroken(client)) {
1564
1540
  const where = executable === "ssh" ? "" : ` at ${executable}, set in remote.SSH.path`;
1565
1541
  fatal(`Your Windows SSH (${client.label}${where}) is too old to reach the workspace.`, oldWindowsSshHint(Boolean(conn.browser_ide_url)), { severity: "warning" });
1566
1542
  return;
1567
1543
  }
1568
1544
  }
1545
+ attempt.stage("ssh_config");
1569
1546
  const alias = aliasFor(conn.ssh_hostname, token);
1570
1547
  pruneStaleHostKeys(parsed);
1571
1548
  const block = buildSshConfigBlock(alias, KEY_PATH, parsed);
1572
- const cfgPath = writeSshConfig(alias, block, buildControlPathBlock(alias, parsed));
1549
+ const cfgPath = await writeSshConfig(alias, block, buildControlPathBlock(alias, parsed));
1573
1550
  if (verbose)
1574
1551
  info(`SSH config written to ${cfgPath} (Host "${alias}")`);
1575
1552
  // ENG-2693 — prove the connection before handing it off (see `sshPreflight`). The
@@ -1582,19 +1559,23 @@ async function connectFlow(token, ideOpt, opts, skillImport) {
1582
1559
  // Called per route, not here once: the native route runs it only AFTER the WSL check
1583
1560
  // (Greptile on #2844), so a candidate whose Windows editor cannot see WSL's config is
1584
1561
  // told that, rather than a generic ssh failure from the wrong side of the pairing.
1562
+ const sshExecutable = sshExecutableFor(ide, ide === "ssh" ? null : readEditorSettings(ide));
1585
1563
  const preflightStops = async (route) => {
1586
- const sshExecutable = sshExecutableFor(route, route === "ssh" ? null : readEditorSettings(route));
1587
1564
  // ENG-2895: up to two 45s attempts, so it gets a spinner; it says nothing when it works.
1588
1565
  const checking = ora("Checking the connection to your workspace...").start();
1566
+ attempt.stage("ssh_preflight");
1567
+ attempt.fact("sshSelection", sshExecutable === "ssh" ? "path" : "editor-setting");
1568
+ attempt.fact("sshVersion", parseOpenSshVersion(sshBanner(sshExecutable) ?? "")?.label);
1589
1569
  const preflight = await sshPreflight(alias, sshExecutable);
1590
1570
  checking.stop();
1591
1571
  if (preflight.ok)
1592
1572
  return false;
1573
+ attempt.evidence(preflight.detail.join("\n"));
1593
1574
  fatal("Your computer couldn't open an SSH connection to the workspace.", sshPreflightHint(preflight.detail, Boolean(conn.browser_ide_url), verbose), {
1594
1575
  severity: "error",
1595
1576
  internalDetail: [
1596
1577
  `route=${route} platform=${process.platform} ssh=${sshExecutable}`,
1597
- `client=${readSshVersionBanner(sshExecutable) ?? "unreadable"}`,
1578
+ `client=${sshBanner(sshExecutable) ?? "unreadable"}`,
1598
1579
  ...preflight.detail,
1599
1580
  ].join("\n"),
1600
1581
  });
@@ -1605,6 +1586,8 @@ async function connectFlow(token, ideOpt, opts, skillImport) {
1605
1586
  if (ide === "ssh") {
1606
1587
  if (await preflightStops("ssh"))
1607
1588
  return;
1589
+ attempt.stage("ssh_handoff");
1590
+ attempt.finish("ssh_ready");
1608
1591
  if (verbose)
1609
1592
  console.log(chalk.dim(` (equivalently: ${conn.ssh_command})`));
1610
1593
  if (opts.tunnel) {
@@ -1630,7 +1613,12 @@ async function connectFlow(token, ideOpt, opts, skillImport) {
1630
1613
  // places its installer puts it, because "not on PATH" was being reported as "not
1631
1614
  // installed" on a machine where the binary was sitting in the application bundle the
1632
1615
  // whole time. Null here means we genuinely could not find a way to drive it.
1616
+ attempt.stage("editor_resolution");
1633
1617
  const resolved = resolveEditorCommand(editor);
1618
+ attempt.editor(ide, resolved?.version.version);
1619
+ attempt.fact("editorFound", Boolean(resolved));
1620
+ attempt.fact("wsl", isWsl());
1621
+ attempt.fact("editorCommit", resolved?.version.commit);
1634
1622
  // ENG-2683 — inside WSL, the editor on PATH is usually the WINDOWS app, and its
1635
1623
  // Remote-SSH reads Windows' ssh config, not the WSL one written above (see
1636
1624
  // `lib/wsl.ts`). Launching it would fail in the editor with nothing on our side, so say
@@ -1652,7 +1640,9 @@ async function connectFlow(token, ideOpt, opts, skillImport) {
1652
1640
  // With nothing resolved there is nothing to probe, and `unverified` is what that means:
1653
1641
  // it is the absence branch's job to say so, below the mismatch refusal, so a candidate
1654
1642
  // whose `code` is Cursor is told THAT rather than that their editor is missing.
1643
+ attempt.stage("editor_resolution");
1655
1644
  const identity = resolved ? identifyEditorCli(editor, resolved.command) : { kind: "unverified" };
1645
+ attempt.fact("editorIdentity", identity.kind);
1656
1646
  // 5b. Point this machine's Copilot Chat at the Litmus gateway (ENG-1475).
1657
1647
  //
1658
1648
  // ONLY ON THE NATIVE VS CODE PATH, and that is the whole reason it is here rather
@@ -1696,8 +1686,7 @@ async function connectFlow(token, ideOpt, opts, skillImport) {
1696
1686
  // no-op that touches nothing.
1697
1687
  //
1698
1688
  // Cursor is still given no laptop-side equivalent of the WRITE: its AI is captured
1699
- // in-container, which is what the Cursor restart (or, off macOS, the quit note at
1700
- // selection time) protects.
1689
+ // in-container through the remote user hooks.
1701
1690
  //
1702
1691
  // ENG-2152 — a MISMATCH refuses below and opens nothing, so it may not WRITE. See
1703
1692
  // `chatConfigActionForIdentity`, which is where that belongs: keying the write on
@@ -1756,8 +1745,6 @@ async function connectFlow(token, ideOpt, opts, skillImport) {
1756
1745
  // ENG-2332 — the CLI is not on PATH and not where the installer puts it. Everything
1757
1746
  // offered here is reachable WITHOUT it; the line this replaced offered
1758
1747
  // `<bin> --remote …`, i.e. the binary whose absence is the message.
1759
- // For Cursor these lines already say to quit and reopen it first, so the selection
1760
- // notice is not restated after them (ENG-2895: it was the third time in one run).
1761
1748
  for (const line of editorNotFoundLines(editor, alias, pageUrl))
1762
1749
  console.log(line);
1763
1750
  return;
@@ -1779,12 +1766,18 @@ async function connectFlow(token, ideOpt, opts, skillImport) {
1779
1766
  info(`Using ${editor.label} at ${resolved.command}`);
1780
1767
  console.log(chalk.dim(` (\`${editor.bin}\` isn't on your PATH. ${editor.label}'s "${editor.shellCommand}" adds it.)`));
1781
1768
  }
1782
- // ENG-3158 — before the workspace read, the Cursor quit and the launch, so a candidate
1769
+ // ENG-3158 — before the workspace read and the launch, so a candidate
1783
1770
  // whose editor would take the connection with the wrong Remote-SSH loses no open Cursor
1784
1771
  // windows for a launch that cannot hold. Read-only: nothing is installed or removed.
1772
+ attempt.stage("extensions");
1773
+ const listed = listEditorExtensions(resolved.command);
1774
+ attempt.fact("extensionInventory", listed === null ? "unavailable" : "read");
1775
+ attempt.fact("requiredExtension", listed === null ? "unknown" : listed.split(/\r?\n/).includes(editor.remoteSshExt) ? "present" : "missing");
1776
+ if (listed !== null)
1777
+ attempt.fact("conflictingExtension", conflictingRemoteSshInstalled(editor, listed));
1785
1778
  if (editor.conflictingRemoteSshExt) {
1786
- const listed = listEditorExtensions(resolved.command);
1787
1779
  if (listed !== null && conflictingRemoteSshInstalled(editor, listed)) {
1780
+ attempt.finish("extension_conflict");
1788
1781
  const editorCmd = pasteableEditorCommand(editor, resolved, process.platform);
1789
1782
  for (const line of conflictingRemoteSshLines(editor, editorCmd, alias, pageUrl))
1790
1783
  console.log(line);
@@ -1796,70 +1789,19 @@ async function connectFlow(token, ideOpt, opts, skillImport) {
1796
1789
  // it. Both, and their order, live in `attemptEditorLaunch`: the guarantee here is a
1797
1790
  // property of the SEQUENCE, so it is held in one testable unit rather than in the
1798
1791
  // arrangement of the lines below. Everything this block still owns is output.
1799
- const probe = sshProbeRunner(alias);
1800
- // ENG-2491, Greptile P1 on #2613 — the baseline probe is taken HERE, before
1801
- // any decision to quit Cursor, and the resulting `seedVerdict` is checked
1802
- // before quitting too. `attemptEditorLaunch` below REFUSES the launch on a
1803
- // mismatched build for any editor with `requiresSeedMatch` set (none today,
1804
- // ENG-2884), and a refusal opens nothing — so a quit sequenced
1805
- // ahead of that check could close every Cursor window on a path that was
1806
- // always going to end in "nothing was opened", with no way given back in
1807
- // locally. Checking the identical rule first means a quit only ever
1808
- // happens on a path already known to go on to a real launch attempt.
1809
- //
1810
- // The reading is taken ONCE and handed to `attemptEditorLaunch` as its own
1811
- // baseline too (`probe: () => baseline` below) — one SSH round trip, not
1812
- // two, for the same reason this file already takes it once for the seed
1813
- // check and the verification baseline together.
1792
+ const probe = sshProbeRunner(alias, sshExecutable);
1793
+ // Read once for both the seed check and attachment baseline.
1814
1794
  const readSpinner = ora(`Checking your workspace...`).start();
1815
1795
  const baseline = probeWorkspace(editor, probe, resolved.version.commit);
1816
1796
  readSpinner.stop();
1817
1797
  const seed = seedVerdict(resolved.version, baseline);
1818
1798
  const seedWillRefuseLaunch = seed.kind === "mismatch" && editor.requiresSeedMatch;
1819
- // ENG-2491 — Cursor reads its hooks configuration once, at its OWN launch,
1820
- // no matter which remote window it then opens (ENG-1728, measured) — so an
1821
- // ALREADY-RUNNING local Cursor never reloads even for a brand new
1822
- // Remote-SSH connection. This quits it with the same graceful, never-a-kill
1823
- // quit `init` uses (see lib/cursor-restart.ts). The launch a few lines
1824
- // below IS the relaunch — connect already opens the editor, so there is
1825
- // nothing else to relaunch it into.
1826
- //
1827
- // ENG-2890 — NOT from Cursor's own terminal: the quit would kill this
1828
- // process before the relaunch, leaving no editor and no command. That case
1829
- // stops before anything is touched, the same way an unconfirmed quit does.
1830
- //
1831
- // NEVER proceeds to launch on an unconfirmed quit. Doing so would hand the
1832
- // new `--remote` argv to the SAME still-running process — Cursor's
1833
- // single-instance lock just forwards it as a new window into a hooks
1834
- // service that already initialised — which is exactly today's silent
1835
- // failure, not a fix for it. So an unconfirmed quit stops here, the same
1836
- // way every other refusal in this file does: nothing opened, workspace
1837
- // still up and waiting, and the exact way back in.
1838
- // Whether the Cursor about to open is a FRESH start, so its prompt recording loads:
1839
- // it was not running, or this command confirmed it quit. Only then may success say the
1840
- // work is recorded (Greptile on #3071); elsewhere (Linux, Windows, an unreadable process
1841
- // list) the candidate was only asked to quit it, and nothing checked that they did.
1799
+ // ENG-3176: never quit an existing desktop for remote hooks. Only a confirmed
1800
+ // cold start needs the historical macOS launch/settle workaround.
1842
1801
  let cursorStartsFresh = false;
1843
1802
  if (!seedWillRefuseLaunch && ide === "cursor" && platformSupportsGracefulCursorRestart(process.platform)) {
1844
1803
  const cursorIo = realCursorProcessIo();
1845
- const presence = cursorIo.isRunning();
1846
- if (presence === "not-running")
1847
- cursorStartsFresh = true;
1848
- if (presence === "running") {
1849
- if (runningInsideCursorTerminal()) {
1850
- for (const line of insideCursorTerminalLines())
1851
- console.log(line);
1852
- return;
1853
- }
1854
- info(CURSOR_RESTARTING_LINE);
1855
- const quit = await attemptQuit(cursorIo);
1856
- if (!quit) {
1857
- for (const line of cursorQuitUnconfirmedLines())
1858
- console.log(line);
1859
- return;
1860
- }
1861
- cursorStartsFresh = true;
1862
- }
1804
+ cursorStartsFresh = cursorIo.isRunning() === "not-running";
1863
1805
  // ENG-2884. A COLD start handed `--remote` loses it: measured twice on a real
1864
1806
  // profile (2026-10-01 21:11 and 2026-10-02 00:26 PT), Cursor 3 came up restoring its
1865
1807
  // previous session (the Agents window plus old folders) and never created the
@@ -1887,7 +1829,8 @@ async function connectFlow(token, ideOpt, opts, skillImport) {
1887
1829
  clientVersion: resolved.version,
1888
1830
  ensureExtension: () => {
1889
1831
  const extSpinner = ora(`Checking ${editor.label} Remote-SSH extension...`).start();
1890
- const state = ensureRemoteSshExtension(editor, resolved.command);
1832
+ const state = ensureRemoteSshExtension(editor, resolved.command, listed);
1833
+ attempt.fact("extensionSetup", state);
1891
1834
  // ENG-2890: an internal step, so it says nothing when it worked.
1892
1835
  if (state === "failed") {
1893
1836
  extSpinner.warn(`Couldn't install ${editor.label}'s Remote-SSH extension. If ${editor.label} asks for it, install it from the Extensions view.`);
@@ -1902,6 +1845,8 @@ async function connectFlow(token, ideOpt, opts, skillImport) {
1902
1845
  },
1903
1846
  launchLogPath: editorLaunchLogPath,
1904
1847
  launch: (logPath) => {
1848
+ attempt.stage("editor_launch");
1849
+ attempt.checkpoint();
1905
1850
  const launched = launches.send(logPath);
1906
1851
  // The launch is not the result, so nothing claims one here. What used to be printed
1907
1852
  // at this point was `✔ Opening <editor> → <alias>`, which is true of a spawn that is
@@ -1911,18 +1856,21 @@ async function connectFlow(token, ideOpt, opts, skillImport) {
1911
1856
  return launched;
1912
1857
  },
1913
1858
  readLaunchLog: () => launches.readLog(readEditorLaunchLog),
1914
- verifyAttached: (baseline, launched) => verifyEditorAttached(editor, baseline, probe, (waited) => {
1915
- if (spinners.verify) {
1916
- spinners.verify.text = `Opening ${editor.label} in your workspace... (${waited}s)`;
1917
- }
1918
- // ENG-2884: if the first request still produced no attached window, ask the
1919
- // (now running) Cursor once more. A window already opening for this folder is
1920
- // focused rather than duplicated, so a slow first attach costs nothing.
1921
- if (shouldResendCursorOpen(editor.ide, cursorStartsFresh, waited, resend)) {
1922
- resend.done = true;
1923
- launches.send(editorLaunchLogPath());
1924
- }
1925
- }, launched, resolved.version.commit),
1859
+ verifyAttached: (baseline, launched) => {
1860
+ attempt.stage("editor_verify");
1861
+ return verifyEditorAttached(editor, baseline, probe, (waited) => {
1862
+ if (spinners.verify) {
1863
+ spinners.verify.text = `Opening ${editor.label} in your workspace... (${waited}s)`;
1864
+ }
1865
+ // ENG-2884: if the first request still produced no attached window, ask the
1866
+ // (now running) Cursor once more. A window already opening for this folder is
1867
+ // focused rather than duplicated, so a slow first attach costs nothing.
1868
+ if (shouldResendCursorOpen(editor.ide, cursorStartsFresh, waited, resend)) {
1869
+ resend.done = true;
1870
+ launches.send(editorLaunchLogPath());
1871
+ }
1872
+ }, launched, resolved.version.commit);
1873
+ },
1926
1874
  });
1927
1875
  if (outcome.kind === "refused") {
1928
1876
  for (const line of editorSeedMismatchLines(editor, outcome.seed, pageUrl, alias)) {
@@ -1932,6 +1880,9 @@ async function connectFlow(token, ideOpt, opts, skillImport) {
1932
1880
  }
1933
1881
  const launchSpinner = spinners.verify;
1934
1882
  if (outcome.kind === "launch-failed") {
1883
+ attempt.finish("launch_failed");
1884
+ attempt.evidence(outcome.error);
1885
+ attempt.evidence(outcome.launchLog);
1935
1886
  // No window was created, so none of the not-connected copy is true here. See
1936
1887
  // `editorLaunchFailedLines`.
1937
1888
  launchSpinner?.fail(`Couldn't start ${editor.label}.`);
@@ -1941,16 +1892,14 @@ async function connectFlow(token, ideOpt, opts, skillImport) {
1941
1892
  console.log(line);
1942
1893
  return;
1943
1894
  }
1895
+ attempt.evidence(outcome.launchLog);
1944
1896
  if (outcome.verdict.kind === "connected") {
1945
- const recordingKnown = editor.ide !== "cursor" || cursorStartsFresh;
1946
- launchSpinner?.succeed(recordingKnown
1947
- ? `${editor.label} is open in your workspace. Your work is recorded.`
1948
- : `${editor.label} is open in your workspace.`);
1949
- if (!recordingKnown)
1950
- skillImport.footer.push(CURSOR_RECORDING_CAVEAT_LINE);
1897
+ attempt.finish("server_detected");
1898
+ launchSpinner?.succeed(`${editor.label}'s server is running in your workspace.`);
1951
1899
  skillImport.footer.push(...editorConnectedLines(editor, alias, verbose));
1952
1900
  }
1953
1901
  else if (outcome.verdict.kind === "unverified") {
1902
+ attempt.finish("unverified");
1954
1903
  // Three-valued on purpose: we could not reach the workspace to ask, which is not the
1955
1904
  // same as knowing nothing came up, and saying either of the other two here would be a
1956
1905
  // claim we cannot make. See `verifyVerdict`.
@@ -1959,6 +1908,7 @@ async function connectFlow(token, ideOpt, opts, skillImport) {
1959
1908
  console.log(line);
1960
1909
  }
1961
1910
  else {
1911
+ attempt.finish("not_connected");
1962
1912
  launchSpinner?.fail(`${editor.label} hasn't connected to your workspace yet.`);
1963
1913
  for (const line of editorNotConnectedLines(editor, alias, pageUrl, verbose))
1964
1914
  console.log(line);