@viraatdas/rudder 3.1.8 → 3.1.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cloud.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { createHash, randomUUID } from "node:crypto";
3
- import { EchoPredictor } from "./predict.js";
3
+ import { AttachSession, containsCtrlC } from "./attach-session.js";
4
4
  import fsp from "node:fs/promises";
5
5
  import os from "node:os";
6
6
  import path from "node:path";
@@ -2465,7 +2465,7 @@ async function attachToWorkspaceResult(result, options) {
2465
2465
  if (process.env.RUDDER_CLOUD_NO_ATTACH === "1") {
2466
2466
  return;
2467
2467
  }
2468
- await runAttach({ kind: "workspace", id: workspaceId, label: `workspace ${workspaceId}` }, { ...options, quietBanner: false });
2468
+ await attachWithReconnect({ kind: "workspace", id: workspaceId, label: `workspace ${workspaceId}` }, options);
2469
2469
  }
2470
2470
  async function workspaceAttachById(workspaceId, options) {
2471
2471
  if (options.json) {
@@ -2478,7 +2478,7 @@ async function workspaceAttachById(workspaceId, options) {
2478
2478
  if (process.env.RUDDER_CLOUD_NO_ATTACH === "1") {
2479
2479
  return;
2480
2480
  }
2481
- await runAttach({ kind: "workspace", id: workspaceId, label: `workspace ${workspaceId}` }, { ...options, quietBanner: false });
2481
+ await attachWithReconnect({ kind: "workspace", id: workspaceId, label: `workspace ${workspaceId}` }, options);
2482
2482
  }
2483
2483
  async function lookupWorkspaceForRepo(options) {
2484
2484
  void options;
@@ -2745,45 +2745,101 @@ async function attach(args, options) {
2745
2745
  await openOnHome(false, { ...options, reattach: true });
2746
2746
  return;
2747
2747
  }
2748
- await runAttach({ kind: "sail", id: sailId, label: sailId }, options);
2748
+ await attachWithReconnect({ kind: "sail", id: sailId, label: sailId }, options);
2749
2749
  }
2750
2750
  const ATTACH_PING_MS = 5_000;
2751
2751
  const ATTACH_PONG_TIMEOUT_MS = 20_000;
2752
2752
  const ATTACH_SLEEP_GAP_MS = 15_000;
2753
+ // Retry at once (a blip, a deploy), then back off; never more than 5s apart,
2754
+ // so coming back to a reachable cloud never waits long.
2755
+ const ATTACH_RETRY_DELAYS_MS = [250, 1_000, 2_000, 3_000, 5_000];
2756
+ // About half an hour of failed tries while awake (asleep, timers do not run).
2757
+ const ATTACH_MAX_RETRIES = 360;
2758
+ const ATTACH_REOPEN_TIMEOUT_MS = 20_000;
2759
+ const ATTACH_HANDSHAKE_TIMEOUT_MS = 20_000;
2753
2760
  // The remote TUI pushes the kitty keyboard enhancement flags once, in its own
2754
2761
  // setup_terminal(), and the worker only keeps the TAIL of its output — so by the
2755
2762
  // time an attach connects, that push is long gone and this terminal never sees
2756
2763
  // it. Same class of bug as the mouse modes below. Without the protocol, macOS
2757
2764
  // terminals report Option+h as the character it TYPES (˙ on a US layout) rather
2758
2765
  // than Alt+h, so ⌥h stops hiding panes, and Alt+[ / Alt+] cannot be encoded at
2759
- // all. Push exactly what the remote asked for: DISAMBIGUATE_ESCAPE_CODES (1) |
2760
- // REPORT_ALTERNATE_KEYS (4). Terminals without the protocol ignore both.
2761
- const KITTY_KEYBOARD_PUSH = "\x1b[>5u";
2762
- const KITTY_KEYBOARD_POP = "\x1b[<u";
2763
- function containsCtrlCInput(data) {
2764
- return data.includes(0x03) || /\x1b\[99;5(?::[123])?u/.test(data.toString("latin1"));
2765
- }
2766
- // Stay attached through a control-plane deploy or a machine restart: say what
2767
- // happened and reconnect, instead of dropping back to the shell silently.
2766
+ // all. AttachSession.hold pushes exactly what the remote asked for:
2767
+ // DISAMBIGUATE_ESCAPE_CODES (1) | REPORT_ALTERNATE_KEYS (4). Terminals without
2768
+ // the protocol ignore it.
2769
+ // Stay attached through a dropped link, a control-plane deploy or a machine
2770
+ // restart. One AttachSession holds the terminal the whole time, so a drop is a
2771
+ // badge in the corner and a reconnect, never a trip back to the shell.
2768
2772
  async function attachWithReconnect(target, options, reopen) {
2769
- let result = await runAttach(target, { ...options, quietBanner: false });
2770
- let attempts = 0;
2771
- while (result === "dropped" && attempts < 120) {
2772
- attempts += 1;
2773
- process.stderr.write(`\r\nLost the connection to Rudder Cloud (laptop slept or the network dropped; your agents kept running). Reconnecting${attempts > 1 ? ` (try ${attempts})` : ""}...\r\n`);
2774
- await sleep(Math.min(15_000, 2_000 * attempts));
2775
- try {
2776
- await reopen?.();
2777
- result = await runAttach(target, { ...options, quietBanner: true });
2778
- attempts = 0;
2773
+ if (options.latencyProbe) {
2774
+ return await runAttach(target, options);
2775
+ }
2776
+ const stdin = process.stdin;
2777
+ const stdout = process.stdout;
2778
+ const session = new AttachSession(target.label, stdin, stdout, {
2779
+ interactive: Boolean(stdin.isTTY && stdout.isTTY) && !options.json,
2780
+ predict: process.env.RUDDER_PREDICT !== "0",
2781
+ });
2782
+ let result = "failed";
2783
+ let gaveUp = false;
2784
+ try {
2785
+ result = await runAttach(target, { ...options, quietBanner: false }, session);
2786
+ let attempts = 0;
2787
+ while (result === "dropped" && !session.hasLeft) {
2788
+ attempts += 1;
2789
+ if (attempts > ATTACH_MAX_RETRIES) {
2790
+ gaveUp = true;
2791
+ break;
2792
+ }
2793
+ session.noteAttempt(attempts);
2794
+ const delay = ATTACH_RETRY_DELAYS_MS[Math.min(attempts, ATTACH_RETRY_DELAYS_MS.length) - 1] ?? 5_000;
2795
+ if (await session.wait(delay)) {
2796
+ break;
2797
+ }
2798
+ try {
2799
+ if (reopen) {
2800
+ // Wakes the machine if it was what went away. Bounded: a hung
2801
+ // request must not stall the retries, and Ctrl+C twice still leaves.
2802
+ await raceLeave(session, withTimeout(reopen(), ATTACH_REOPEN_TIMEOUT_MS));
2803
+ }
2804
+ if (session.hasLeft) {
2805
+ break;
2806
+ }
2807
+ result = await runAttach(target, { ...options, quietBanner: true }, session);
2808
+ attempts = 0;
2809
+ }
2810
+ catch {
2811
+ result = "dropped";
2812
+ }
2779
2813
  }
2780
- catch {
2781
- result = "dropped";
2814
+ if (session.hasLeft && result === "dropped") {
2815
+ result = "exited";
2782
2816
  }
2783
2817
  }
2818
+ finally {
2819
+ session.release();
2820
+ }
2821
+ if (gaveUp) {
2822
+ process.stderr.write(`\nCould not reach Rudder Cloud for a while. Your agents are still running there; run \`rudder cloud\` to come back.\n`);
2823
+ }
2824
+ else if (session.hasLeft && result === "exited") {
2825
+ process.stderr.write(`\nLeft ${target.label}. Your agents keep running; run \`rudder cloud\` to come back.\n`);
2826
+ }
2784
2827
  return result;
2785
2828
  }
2786
- async function runAttach(target, options) {
2829
+ function withTimeout(promise, ms) {
2830
+ return new Promise((resolve, reject) => {
2831
+ const timer = setTimeout(() => reject(new Error("timed out")), ms);
2832
+ timer.unref?.();
2833
+ promise.then((value) => { clearTimeout(timer); resolve(value); }, (error) => { clearTimeout(timer); reject(error); });
2834
+ });
2835
+ }
2836
+ function raceLeave(session, promise) {
2837
+ return new Promise((resolve, reject) => {
2838
+ const off = session.onLeave(() => resolve(undefined));
2839
+ promise.then((value) => { off(); resolve(value); }, (error) => { off(); reject(error); });
2840
+ });
2841
+ }
2842
+ async function runAttach(target, options, sharedSession) {
2787
2843
  const client = await cloudClient({ requireToken: true });
2788
2844
  const baseUrl = client.baseUrl;
2789
2845
  const state = await loadCloudAuth();
@@ -2792,12 +2848,23 @@ async function runAttach(target, options) {
2792
2848
  if (!token) {
2793
2849
  throw new Error("Not logged in to Rudder Cloud. Run `rudder login` first.");
2794
2850
  }
2795
- const wsUrl = baseUrl.replace(/^http/, "ws").replace(/\/$/, "")
2796
- + `/api/rudder/${target.kind}/${encodeURIComponent(target.id)}/attach`;
2797
2851
  const stdin = process.stdin;
2798
2852
  const stdout = process.stdout;
2799
2853
  const probeMode = Boolean(options.latencyProbe);
2800
2854
  const isInteractive = Boolean(stdin.isTTY && stdout.isTTY) && !probeMode;
2855
+ // A shared session outlives this connection (attachWithReconnect); an own
2856
+ // one is released with it.
2857
+ const session = sharedSession ?? new AttachSession(target.label, stdin, stdout, {
2858
+ interactive: isInteractive && !options.json,
2859
+ predict: process.env.RUDDER_PREDICT !== "0",
2860
+ });
2861
+ const reconnects = Boolean(sharedSession);
2862
+ // The dashboard is already on the screen (a reconnect, not a first attach).
2863
+ const resuming = session.connections > 0 && session.drewFrame;
2864
+ // The relay hands control back to this same terminal when it returns,
2865
+ // instead of keeping its dead socket in charge until a ping times out.
2866
+ const wsUrl = baseUrl.replace(/^http/, "ws").replace(/\/$/, "")
2867
+ + `/api/rudder/${target.kind}/${encodeURIComponent(target.id)}/attach?client=${encodeURIComponent(session.clientId)}`;
2801
2868
  return await new Promise((resolve, reject) => {
2802
2869
  const socket = new WebSocket(wsUrl, {
2803
2870
  headers: { authorization: `Bearer ${token}` },
@@ -2810,6 +2877,8 @@ async function runAttach(target, options) {
2810
2877
  // are bounded (~100KB worst-case repaint), so the bandwidth cost of
2811
2878
  // skipping compression is negligible next to the latency it removes.
2812
2879
  perMessageDeflate: false,
2880
+ // A connect into a dead network can otherwise sit for minutes.
2881
+ handshakeTimeout: ATTACH_HANDSHAKE_TIMEOUT_MS,
2813
2882
  });
2814
2883
  socket.binaryType = "nodebuffer";
2815
2884
  // Every deliberate close goes through socket.close() from this side; any
@@ -2822,19 +2891,18 @@ async function runAttach(target, options) {
2822
2891
  });
2823
2892
  let opened = false;
2824
2893
  let cleaned = false;
2825
- let firstFrameRendered = false;
2826
- let workerConnected = false;
2894
+ let firstFrameRendered = resuming;
2895
+ // What the relay last said about the machine end: "down" only once it has
2896
+ // said so, so a key typed before the first status still goes through.
2897
+ let workerState = "unknown";
2827
2898
  let hasControl = true;
2828
- let readOnlyNoticeShown = false;
2829
2899
  let result = "failed";
2830
- const splashAllowed = isInteractive && !options.json && !options.quietBanner;
2900
+ const splashAllowed = isInteractive && !options.json && !options.quietBanner && !resuming;
2831
2901
  const splash = splashAllowed ? new AttachSplash(stdout, target.label) : null;
2832
2902
  // Predictive local echo (src/predict.ts): typed characters appear at the
2833
2903
  // caret at once and the cloud's frame confirms them. RUDDER_PREDICT=0
2834
- // turns it off; it only engages once the round trip is worth hiding.
2835
- const predictor = isInteractive && process.env.RUDDER_PREDICT !== "0"
2836
- ? new EchoPredictor(stdout.columns ?? 120, stdout.rows ?? 32, (text) => stdout.write(text))
2837
- : null;
2904
+ // turns the drawing off; the model stays, the reconnect badge uses it.
2905
+ const predictor = session.predictor;
2838
2906
  const sendResize = () => {
2839
2907
  if (socket.readyState !== WebSocket.OPEN) {
2840
2908
  return;
@@ -2844,24 +2912,36 @@ async function runAttach(target, options) {
2844
2912
  predictor?.resize(cols, rows);
2845
2913
  socket.send(JSON.stringify({ type: "resize", cols, rows }));
2846
2914
  };
2915
+ // Ask the dashboard to draw every cell again. The replay the relay sends
2916
+ // on attach is only the tail of its output; this makes the screen exact.
2917
+ const requestRepaint = () => {
2918
+ if (socket.readyState === WebSocket.OPEN && !probeMode) {
2919
+ socket.send(JSON.stringify({ type: "repaint" }));
2920
+ }
2921
+ };
2922
+ const finish = (value, message) => {
2923
+ cleanup(false);
2924
+ if (message) {
2925
+ process.stderr.write(message);
2926
+ }
2927
+ resolve(value);
2928
+ };
2847
2929
  let lastCtrlC = 0;
2848
2930
  const onStdin = (chunk) => {
2931
+ const buffer = typeof chunk === "string" ? Buffer.from(chunk, "utf8") : chunk;
2849
2932
  if (socket.readyState !== WebSocket.OPEN) {
2850
2933
  return;
2851
2934
  }
2852
- const buffer = typeof chunk === "string" ? Buffer.from(chunk, "utf8") : chunk;
2853
- const hasCtrlC = containsCtrlCInput(buffer);
2854
- // Once the relay says the worker is gone, Ctrl+C means "leave this local
2855
- // attach". Forwarding/buffering it would kill the next dashboard as soon
2856
- // as the persistent machine reconnects.
2857
- if (hasCtrlC && !workerConnected) {
2935
+ const hasCtrlC = containsCtrlC(buffer);
2936
+ // While the loading splash is up (no remote frame has rendered yet),
2937
+ // Ctrl+C should cancel the local attach instead of being forwarded to
2938
+ // a remote dashboard the user can't see.
2939
+ if (hasCtrlC && !firstFrameRendered) {
2858
2940
  try {
2859
- socket.close(1000, "client-cancel-disconnected");
2941
+ socket.close(1000, "client-cancel");
2860
2942
  }
2861
2943
  catch { /* ignore */ }
2862
- cleanup();
2863
- process.stderr.write("\nDetached from disconnected cloud worker.\n");
2864
- resolve("exited");
2944
+ finish("exited", "\nCancelled.\n");
2865
2945
  return;
2866
2946
  }
2867
2947
  // An observer must always be able to leave without sending an interrupt
@@ -2871,33 +2951,23 @@ async function runAttach(target, options) {
2871
2951
  socket.close(1000, "read-only-client-cancel");
2872
2952
  }
2873
2953
  catch { /* ignore */ }
2874
- cleanup();
2875
- process.stderr.write("\nDetached read-only cloud attachment.\n");
2876
- resolve("exited");
2954
+ finish("exited", "\nDetached read-only cloud attachment.\n");
2877
2955
  return;
2878
2956
  }
2879
2957
  if (!hasControl) {
2880
- if (!readOnlyNoticeShown) {
2881
- process.stderr.write("\nThis cloud attachment is read-only while another terminal controls the session.\n");
2882
- readOnlyNoticeShown = true;
2883
- }
2958
+ session.showStatus("Read-only · another terminal has control");
2884
2959
  return;
2885
2960
  }
2886
- // While the loading splash is up (no remote frame has rendered yet),
2887
- // Ctrl+C should cancel the local attach instead of being forwarded to
2888
- // a remote dashboard the user can't see.
2889
- if (hasCtrlC && !firstFrameRendered) {
2890
- try {
2891
- socket.close(1000, "client-cancel");
2892
- }
2893
- catch { /* ignore */ }
2894
- cleanup();
2895
- if (!opened) {
2896
- reject(new Error("Cloud attach cancelled"));
2897
- }
2898
- else {
2899
- process.stderr.write("\nCancelled.\n");
2900
- resolve("exited");
2961
+ // The machine end is away (its dashboard restarting, the machine
2962
+ // waking). Forwarding Ctrl+C would kill the next dashboard as soon as it
2963
+ // is back, so it never goes; pressed twice it leaves this attach.
2964
+ if (hasCtrlC && workerState === "down") {
2965
+ if (session.ctrlCToLeave()) {
2966
+ try {
2967
+ socket.close(1000, "client-cancel-disconnected");
2968
+ }
2969
+ catch { /* ignore */ }
2970
+ finish("exited");
2901
2971
  }
2902
2972
  return;
2903
2973
  }
@@ -2907,13 +2977,11 @@ async function runAttach(target, options) {
2907
2977
  if (hasCtrlC && firstFrameRendered) {
2908
2978
  const now = Date.now();
2909
2979
  if (now - lastCtrlC < 2000) {
2910
- process.stderr.write("\nForce-exiting local attach (press Ctrl+C again to re-enter).\n");
2911
2980
  try {
2912
2981
  socket.close(1000, "client-force-quit");
2913
2982
  }
2914
2983
  catch { /* ignore */ }
2915
- cleanup();
2916
- resolve("exited");
2984
+ finish("exited", "\nForce-exiting local attach (press Ctrl+C again to re-enter).\n");
2917
2985
  return;
2918
2986
  }
2919
2987
  lastCtrlC = now;
@@ -2926,6 +2994,7 @@ async function runAttach(target, options) {
2926
2994
  sendResize();
2927
2995
  }
2928
2996
  splash?.redraw();
2997
+ session.noteResize();
2929
2998
  };
2930
2999
  // Liveness. A laptop that slept leaves this socket half-open: nothing
2931
3000
  // errors, the screen just freezes, and the relay has meanwhile dropped us
@@ -2963,71 +3032,46 @@ async function runAttach(target, options) {
2963
3032
  }
2964
3033
  }, ATTACH_PING_MS);
2965
3034
  liveness.unref?.();
2966
- const cleanup = () => {
3035
+ // Leaving (Ctrl+C twice) while this connection is still being made.
3036
+ const offLeave = session.onLeave(() => {
3037
+ if (!opened && !cleaned) {
3038
+ try {
3039
+ socket.terminate();
3040
+ }
3041
+ catch { /* ignore */ }
3042
+ cleanup(false);
3043
+ resolve("exited");
3044
+ }
3045
+ });
3046
+ // keepTerminal: the link dropped (or a reconnect could not open) and the
3047
+ // caller reconnects, so the screen, raw mode and the keys stay ours.
3048
+ const cleanup = (keepTerminal) => {
2967
3049
  if (cleaned) {
2968
3050
  return;
2969
3051
  }
2970
3052
  cleaned = true;
3053
+ offLeave();
2971
3054
  clearInterval(liveness);
2972
- predictor?.dispose();
2973
3055
  stdin.off("data", onStdin);
2974
3056
  stdout.off("resize", onResize);
2975
3057
  process.off("SIGINT", onSigint);
2976
- splash?.dispose();
2977
- if (isInteractive && stdin.isTTY) {
2978
- // Hand the terminal back the way we found it: pop the keyboard flags we
2979
- // pushed, then disable local mouse capture before leaving raw mode so the
2980
- // user's shell prompt does not get spammed with mouse SGR bytes.
2981
- try {
2982
- stdout.write(KITTY_KEYBOARD_POP);
2983
- }
2984
- catch {
2985
- // ignore
2986
- }
2987
- try {
2988
- stdout.write(KITTY_KEYBOARD_POP);
2989
- stdout.write("\x1b[?1006l\x1b[?1002l\x1b[?1000l");
2990
- }
2991
- catch {
2992
- // ignore
2993
- }
2994
- // Release the tab title so the user's shell prompt can rewrite it.
2995
- try {
2996
- stdout.write("\x1b]0;\x07");
2997
- }
2998
- catch {
2999
- // ignore
3000
- }
3001
- try {
3002
- stdin.setRawMode(false);
3003
- }
3004
- catch {
3005
- // ignore
3006
- }
3058
+ splash?.stop();
3059
+ if (keepTerminal) {
3060
+ session.enterGap();
3007
3061
  }
3008
- if (splashAllowed) {
3009
- // We held the local terminal in the alt-screen buffer for the entire
3010
- // attach session (see AttachSplash.handoff). Restore the main buffer
3011
- // and cursor now so the user is dropped back to their shell prompt
3012
- // instead of a blank alt-screen with the dashboard frozen on it.
3013
- try {
3014
- stdout.write("\x1b[?1049l\x1b[?25h");
3015
- }
3016
- catch {
3017
- // ignore
3018
- }
3062
+ else {
3063
+ session.release();
3019
3064
  }
3020
- stdin.pause();
3021
3065
  };
3022
3066
  const onSigint = () => {
3023
3067
  // Belt-and-suspenders: if raw mode failed for any reason, Node still
3024
3068
  // sees SIGINT. Close cleanly so the user never gets a frozen terminal.
3025
- process.stderr.write("\nReceived SIGINT, closing cloud attach.\n");
3026
3069
  try {
3027
3070
  socket.close(1000, "sigint");
3028
3071
  }
3029
3072
  catch { /* ignore */ }
3030
- cleanup();
3073
+ cleanup(false);
3074
+ process.stderr.write("\nReceived SIGINT, closing cloud attach.\n");
3031
3075
  if (!opened) {
3032
3076
  reject(new Error("Cloud attach cancelled"));
3033
3077
  }
@@ -3123,6 +3167,9 @@ async function runAttach(target, options) {
3123
3167
  };
3124
3168
  socket.on("open", () => {
3125
3169
  opened = true;
3170
+ session.connections += 1;
3171
+ // From here this connection reads the keys; the gap hands them over.
3172
+ session.exitGap();
3126
3173
  // Measure the round trip at once: predictive echo waits on it.
3127
3174
  try {
3128
3175
  socket.ping();
@@ -3149,48 +3196,35 @@ async function runAttach(target, options) {
3149
3196
  }
3150
3197
  // Label this terminal tab so the user can find the right rudder cloud
3151
3198
  // session at a glance instead of squinting at a row of "ghostty" tabs.
3152
- try {
3153
- stdout.write(`\x1b]0;Rudder cloud: ${target.label}\x07`);
3199
+ if (isInteractive) {
3200
+ try {
3201
+ stdout.write(`\x1b]0;Rudder cloud: ${target.label}\x07`);
3202
+ }
3203
+ catch {
3204
+ // ignore
3205
+ }
3154
3206
  }
3155
- catch {
3156
- // ignore
3207
+ // The alternate screen, raw keys, local mouse capture (the remote's own
3208
+ // enable sequences arrive as output, which the local terminal never
3209
+ // treats as mode switches) and the kitty keyboard flags.
3210
+ if (isInteractive) {
3211
+ session.hold();
3157
3212
  }
3158
3213
  if (splash) {
3159
3214
  splash.start();
3160
3215
  splash.setStatus(`Booting cloud workspace · ${target.label}`);
3161
3216
  }
3162
- else if (!options.json && !options.quietBanner) {
3217
+ else if (!options.json && !options.quietBanner && !resuming) {
3163
3218
  const tail = isInteractive ? " (Ctrl+C sends to remote; close this pane to detach)" : "";
3164
3219
  process.stderr.write(`Attached to ${target.label}${tail}\n`);
3165
3220
  }
3166
3221
  sendResize();
3167
- if (isInteractive) {
3168
- try {
3169
- stdin.setRawMode(true);
3170
- }
3171
- catch {
3172
- // ignore
3173
- }
3174
- // Enable mouse tracking on the LOCAL terminal so scroll/click events
3175
- // get forwarded into stdin. The remote rudder TUI also emits these
3176
- // enable sequences, but they arrive as PTY output bytes and the local
3177
- // terminal does not interpret output bytes as mode toggles. We have
3178
- // to enable mouse capture locally too.
3179
- try {
3180
- stdout.write("\x1b[?1000h\x1b[?1002h\x1b[?1006h");
3181
- }
3182
- catch {
3183
- // ignore
3184
- }
3185
- // Ask the local terminal for the kitty keyboard protocol on the remote's
3186
- // behalf, so real modifiers reach the remote dashboard.
3187
- try {
3188
- stdout.write(KITTY_KEYBOARD_PUSH);
3189
- }
3190
- catch {
3191
- // ignore
3192
- }
3222
+ // Typed while the link was down: sent first, in order.
3223
+ const queued = session.takeQueued();
3224
+ if (queued) {
3225
+ socket.send(queued, { binary: true });
3193
3226
  }
3227
+ requestRepaint();
3194
3228
  if (!probeMode) {
3195
3229
  stdin.resume();
3196
3230
  stdin.on("data", onStdin);
@@ -3232,6 +3266,14 @@ async function runAttach(target, options) {
3232
3266
  probeEchoWaiter?.(performance.now());
3233
3267
  return;
3234
3268
  }
3269
+ if (!session.drewFrame) {
3270
+ session.drewFrame = true;
3271
+ // A link that dropped while the loading screen was up leaves its
3272
+ // text behind; the splash wipes its own on handoff.
3273
+ if (!splash && isInteractive) {
3274
+ stdout.write("\x1b[2J\x1b[H");
3275
+ }
3276
+ }
3235
3277
  if (!firstFrameRendered) {
3236
3278
  firstFrameRendered = true;
3237
3279
  splash?.handoff();
@@ -3254,18 +3296,20 @@ async function runAttach(target, options) {
3254
3296
  handleControlText(text);
3255
3297
  });
3256
3298
  socket.on("close", (code, reason) => {
3257
- cleanup();
3299
+ const dropped = opened && !closedByUs && code !== 4410 && code !== 1000;
3300
+ // A reconnect that could not open is part of the same gap.
3301
+ cleanup(reconnects && session.connections > 0 && (dropped || !opened));
3258
3302
  if (!opened) {
3259
3303
  const reasonText = reason && reason.length ? reason.toString("utf8") : "";
3260
3304
  reject(new Error(`Cloud attach failed (code ${code}${reasonText ? `: ${reasonText}` : ""})`));
3261
3305
  return;
3262
3306
  }
3263
3307
  // 4410: the workspace was deleted; nothing to come back to.
3264
- resolve(!closedByUs && code !== 4410 && code !== 1000 ? "dropped" : result);
3308
+ resolve(dropped ? "dropped" : result);
3265
3309
  });
3266
3310
  socket.on("error", (err) => {
3267
3311
  if (!opened) {
3268
- cleanup();
3312
+ cleanup(reconnects && session.connections > 0);
3269
3313
  reject(new Error(`Cloud attach failed: ${err.message}`));
3270
3314
  }
3271
3315
  });
@@ -3295,63 +3339,74 @@ async function runAttach(target, options) {
3295
3339
  }
3296
3340
  return;
3297
3341
  }
3298
- if (message.type === "status") {
3299
- if (message.state === "worker-connected") {
3300
- workerConnected = true;
3342
+ if (message.type !== "status") {
3343
+ return;
3344
+ }
3345
+ const wasDown = workerState === "down";
3346
+ if (message.state === "worker-connected") {
3347
+ workerState = "up";
3348
+ }
3349
+ else if (message.state === "worker-disconnected" || message.state === "worker-waiting") {
3350
+ workerState = "down";
3351
+ }
3352
+ if (message.control === "read-only" || message.state === "read-only") {
3353
+ hasControl = false;
3354
+ }
3355
+ else if (message.control === "active" || message.state === "controller-promoted") {
3356
+ const promoted = !hasControl;
3357
+ hasControl = true;
3358
+ if (promoted || message.state === "controller-promoted") {
3359
+ sendResize();
3301
3360
  }
3302
- else if (message.state === "worker-disconnected" || message.state === "worker-waiting") {
3303
- workerConnected = false;
3361
+ }
3362
+ // If the worker dies before we've ever seen a remote frame, the
3363
+ // session is effectively dead. Don't sit on a splash spinner pretending
3364
+ // it'll come back; bail.
3365
+ if (message.state === "worker-disconnected" && !firstFrameRendered) {
3366
+ try {
3367
+ socket.close(1000, "worker-gone");
3304
3368
  }
3305
- if (message.control === "read-only" || message.state === "read-only") {
3306
- hasControl = false;
3369
+ catch { /* ignore */ }
3370
+ cleanup(false);
3371
+ process.stderr.write("\nCloud worker exited before the dashboard started.\n");
3372
+ resolve("failed");
3373
+ return;
3374
+ }
3375
+ if (splash && !firstFrameRendered) {
3376
+ if (message.state === "worker-waiting") {
3377
+ splash.setStatus(`Waiting for cloud worker · ${target.label}`);
3307
3378
  }
3308
- else if (message.control === "active" || message.state === "controller-promoted") {
3309
- const promoted = !hasControl;
3310
- hasControl = true;
3311
- if (promoted || message.state === "controller-promoted") {
3312
- sendResize();
3313
- }
3379
+ else if (message.state === "worker-connected") {
3380
+ splash.setStatus(`Cloud worker connected · ${target.label}`);
3381
+ sendResize();
3382
+ setTimeout(sendResize, 150);
3314
3383
  }
3315
- // If the worker dies before we've ever seen a remote frame, the
3316
- // session is effectively dead. Don't sit on a splash spinner pretending
3317
- // it'll come back; bail.
3318
- if (message.state === "worker-disconnected" && !firstFrameRendered) {
3319
- splash?.dispose();
3320
- try {
3321
- socket.close(1000, "worker-gone");
3322
- }
3323
- catch { /* ignore */ }
3324
- cleanup();
3325
- if (!opened) {
3326
- reject(new Error("Cloud worker exited before the dashboard started"));
3327
- }
3328
- else {
3329
- process.stderr.write("\nCloud worker exited before the dashboard started.\n");
3330
- result = "failed";
3331
- resolve(result);
3332
- }
3333
- return;
3384
+ else if (message.state === "input-buffered") {
3385
+ splash.setStatus(`Reconnecting cloud input · ${target.label}`);
3334
3386
  }
3335
- if (splash && !firstFrameRendered) {
3336
- if (message.state === "worker-waiting") {
3337
- splash.setStatus(`Waiting for cloud worker · ${target.label}`);
3338
- }
3339
- else if (message.state === "worker-connected") {
3340
- splash.setStatus(`Cloud worker connected · ${target.label}`);
3341
- sendResize();
3342
- setTimeout(sendResize, 150);
3343
- }
3344
- else if (message.state === "input-buffered") {
3345
- splash.setStatus(`Reconnecting cloud input · ${target.label}`);
3346
- }
3387
+ return;
3388
+ }
3389
+ if (options.json || probeMode) {
3390
+ return;
3391
+ }
3392
+ // Past the splash nothing is written into the dashboard's picture: the
3393
+ // state goes in the corner badge and comes off when it is over.
3394
+ if (!hasControl) {
3395
+ session.showStatus("Read-only · another terminal has control");
3396
+ }
3397
+ else if (workerState === "down") {
3398
+ session.showStatus(message.state === "worker-waiting"
3399
+ ? "Waiting for the cloud machine · your typing is kept"
3400
+ : "Cloud machine reconnecting · your typing is kept");
3401
+ }
3402
+ else if (workerState === "up" || message.state === "controller-promoted") {
3403
+ if (session.statusText) {
3404
+ session.clearStatus();
3347
3405
  }
3348
- else if (!options.json && !options.quietBanner) {
3349
- if (message.state === "worker-disconnected") {
3350
- process.stderr.write("\nCloud worker disconnected; waiting for reconnect...\n");
3351
- }
3352
- else if (message.state === "worker-connected") {
3353
- process.stderr.write("Cloud worker connected.\n");
3354
- }
3406
+ if (wasDown && workerState === "up") {
3407
+ // A dashboard that kept running behind a relay restart shows the
3408
+ // same picture; one that restarted paints anew. Either way, exact.
3409
+ requestRepaint();
3355
3410
  }
3356
3411
  }
3357
3412
  }
@@ -3370,12 +3425,14 @@ class AttachSplash {
3370
3425
  this.label = label;
3371
3426
  this.status = `Connecting to ${label}`;
3372
3427
  }
3428
+ // The alternate screen is AttachSession's (held for the whole attach,
3429
+ // reconnects included); the splash only draws inside it.
3373
3430
  start() {
3374
3431
  if (this.active) {
3375
3432
  return;
3376
3433
  }
3377
3434
  this.active = true;
3378
- this.stdout.write("\x1b[?1049h\x1b[?25l");
3435
+ this.stdout.write("\x1b[?25l");
3379
3436
  this.redraw();
3380
3437
  this.timer = setInterval(() => {
3381
3438
  this.frame = (this.frame + 1) % AttachSplash.FRAMES.length;
@@ -3414,25 +3471,14 @@ class AttachSplash {
3414
3471
  return;
3415
3472
  }
3416
3473
  this.stop();
3417
- // Stay in the alt-screen buffer for the rest of the attach session. The
3418
- // remote dashboard will render into this same buffer, and runAttach's
3419
- // cleanup() will exit the alt screen on shutdown. Wipe the splash content
3420
- // first so the remote's partial frames don't render on top of leftover
3421
- // spinner text (and so any tiny window before the remote's first frame
3422
- // is a clean buffer, not main-buffer scrollback).
3474
+ // Wipe the splash content first so the remote's partial frames don't
3475
+ // render on top of leftover spinner text.
3423
3476
  this.stdout.write("\x1b[2J\x1b[H\x1b[?25h");
3424
3477
  }
3425
- dispose() {
3478
+ stop() {
3426
3479
  if (!this.active) {
3427
3480
  return;
3428
3481
  }
3429
- this.stop();
3430
- // dispose() is the abort path (worker died, user cancelled before any
3431
- // remote frame). Leave the alt screen so the user is dropped back to their
3432
- // shell prompt instead of staring at a frozen spinner buffer.
3433
- this.stdout.write("\x1b[?1049l\x1b[?25h");
3434
- }
3435
- stop() {
3436
3482
  this.active = false;
3437
3483
  if (this.timer) {
3438
3484
  clearInterval(this.timer);
@@ -3465,7 +3511,7 @@ async function maybeAutoAttach(result, options) {
3465
3511
  return;
3466
3512
  }
3467
3513
  try {
3468
- await runAttach({ kind: "sail", id: sailId, label: sailId }, { ...options, quietBanner: false });
3514
+ await attachWithReconnect({ kind: "sail", id: sailId, label: sailId }, options);
3469
3515
  }
3470
3516
  catch (error) {
3471
3517
  console.warn(`Could not attach to ${sailId}: ${error instanceof Error ? error.message : String(error)}`);
@@ -3712,10 +3758,12 @@ const LAPTOP_CONFIG_FILES = [
3712
3758
  ];
3713
3759
  async function laptopConfigFiles() {
3714
3760
  const names = [...LAPTOP_CONFIG_FILES, "~/.aws/config", "~/.aws/credentials"];
3715
- const hooksDir = path.join(os.homedir(), ".claude", "hooks");
3716
- for (const entry of await fsp.readdir(hooksDir, { withFileTypes: true }).catch(() => [])) {
3717
- if (entry.isFile()) {
3718
- names.push(`~/.claude/hooks/${entry.name}`);
3761
+ // Hook scripts the settings above point at.
3762
+ for (const dir of [".claude/hooks", ".codex/libra-hooks"]) {
3763
+ for (const entry of await fsp.readdir(path.join(os.homedir(), dir), { withFileTypes: true }).catch(() => [])) {
3764
+ if (entry.isFile()) {
3765
+ names.push(`~/${dir}/${entry.name}`);
3766
+ }
3719
3767
  }
3720
3768
  }
3721
3769
  const out = [];
@@ -4261,6 +4309,38 @@ async function homeCommand(args, options) {
4261
4309
  }
4262
4310
  return;
4263
4311
  }
4312
+ if (sub === "region") {
4313
+ const region = (args[1] ?? "").trim().toLowerCase();
4314
+ if (!region) {
4315
+ throw new Error("Usage: rudder cloud home region <fly region, e.g. sjc>");
4316
+ }
4317
+ if (process.env.RUDDER_CLOUD_RUNTIME === "home") {
4318
+ throw new Error("run this from your laptop: moving the home machine restarts it.");
4319
+ }
4320
+ const started = await client.request("/api/rudder/home/region", { method: "POST", body: { region } });
4321
+ console.log(`Moving the home machine ${started.move.from} -> ${started.move.to}. Its disk is copied; the old one is kept as a backup.`);
4322
+ let last = "";
4323
+ for (let i = 0; i < 400; i += 1) {
4324
+ await sleep(3000);
4325
+ const { move } = await client.request("/api/rudder/home/region", { method: "GET" });
4326
+ if (!move) {
4327
+ continue;
4328
+ }
4329
+ if (move.state !== last) {
4330
+ console.log(` ${move.state}`);
4331
+ last = move.state;
4332
+ }
4333
+ if (move.state === "failed") {
4334
+ throw new Error(`the move failed: ${move.error ?? "unknown error"}`);
4335
+ }
4336
+ if (move.state === "done") {
4337
+ console.log(`Done. The old disk (${move.oldVolumeId ?? "?"}) in ${move.from} is untouched; delete it once everything checks out.`);
4338
+ return;
4339
+ }
4340
+ }
4341
+ console.log("Still moving; check `rudder cloud home` in a few minutes.");
4342
+ return;
4343
+ }
4264
4344
  if (sub === "open") {
4265
4345
  const repo = args[1];
4266
4346
  if (!repo || !/^[\w.-]+\/[\w.-]+$/.test(repo)) {
@@ -4280,7 +4360,7 @@ async function homeCommand(args, options) {
4280
4360
  return;
4281
4361
  }
4282
4362
  if (sub !== "status") {
4283
- throw new Error("Usage: rudder cloud home [status|start|stop|open <owner/repo>|runs <runId...>|delete <runId...>]");
4363
+ throw new Error("Usage: rudder cloud home [status|start|stop|region <code>|open <owner/repo>|runs <runId...>|delete <runId...>]");
4284
4364
  }
4285
4365
  const result = await client.request("/api/rudder/home", { method: "GET" });
4286
4366
  if (options.json) {