@viraatdas/rudder 3.1.7 → 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,5 +1,6 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { createHash, randomUUID } from "node:crypto";
3
+ import { AttachSession, containsCtrlC } from "./attach-session.js";
3
4
  import fsp from "node:fs/promises";
4
5
  import os from "node:os";
5
6
  import path from "node:path";
@@ -2464,7 +2465,7 @@ async function attachToWorkspaceResult(result, options) {
2464
2465
  if (process.env.RUDDER_CLOUD_NO_ATTACH === "1") {
2465
2466
  return;
2466
2467
  }
2467
- await runAttach({ kind: "workspace", id: workspaceId, label: `workspace ${workspaceId}` }, { ...options, quietBanner: false });
2468
+ await attachWithReconnect({ kind: "workspace", id: workspaceId, label: `workspace ${workspaceId}` }, options);
2468
2469
  }
2469
2470
  async function workspaceAttachById(workspaceId, options) {
2470
2471
  if (options.json) {
@@ -2477,7 +2478,7 @@ async function workspaceAttachById(workspaceId, options) {
2477
2478
  if (process.env.RUDDER_CLOUD_NO_ATTACH === "1") {
2478
2479
  return;
2479
2480
  }
2480
- await runAttach({ kind: "workspace", id: workspaceId, label: `workspace ${workspaceId}` }, { ...options, quietBanner: false });
2481
+ await attachWithReconnect({ kind: "workspace", id: workspaceId, label: `workspace ${workspaceId}` }, options);
2481
2482
  }
2482
2483
  async function lookupWorkspaceForRepo(options) {
2483
2484
  void options;
@@ -2744,45 +2745,101 @@ async function attach(args, options) {
2744
2745
  await openOnHome(false, { ...options, reattach: true });
2745
2746
  return;
2746
2747
  }
2747
- await runAttach({ kind: "sail", id: sailId, label: sailId }, options);
2748
+ await attachWithReconnect({ kind: "sail", id: sailId, label: sailId }, options);
2748
2749
  }
2749
2750
  const ATTACH_PING_MS = 5_000;
2750
2751
  const ATTACH_PONG_TIMEOUT_MS = 20_000;
2751
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;
2752
2760
  // The remote TUI pushes the kitty keyboard enhancement flags once, in its own
2753
2761
  // setup_terminal(), and the worker only keeps the TAIL of its output — so by the
2754
2762
  // time an attach connects, that push is long gone and this terminal never sees
2755
2763
  // it. Same class of bug as the mouse modes below. Without the protocol, macOS
2756
2764
  // terminals report Option+h as the character it TYPES (˙ on a US layout) rather
2757
2765
  // than Alt+h, so ⌥h stops hiding panes, and Alt+[ / Alt+] cannot be encoded at
2758
- // all. Push exactly what the remote asked for: DISAMBIGUATE_ESCAPE_CODES (1) |
2759
- // REPORT_ALTERNATE_KEYS (4). Terminals without the protocol ignore both.
2760
- const KITTY_KEYBOARD_PUSH = "\x1b[>5u";
2761
- const KITTY_KEYBOARD_POP = "\x1b[<u";
2762
- function containsCtrlCInput(data) {
2763
- return data.includes(0x03) || /\x1b\[99;5(?::[123])?u/.test(data.toString("latin1"));
2764
- }
2765
- // Stay attached through a control-plane deploy or a machine restart: say what
2766
- // 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.
2767
2772
  async function attachWithReconnect(target, options, reopen) {
2768
- let result = await runAttach(target, { ...options, quietBanner: false });
2769
- let attempts = 0;
2770
- while (result === "dropped" && attempts < 120) {
2771
- attempts += 1;
2772
- 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`);
2773
- await sleep(Math.min(15_000, 2_000 * attempts));
2774
- try {
2775
- await reopen?.();
2776
- result = await runAttach(target, { ...options, quietBanner: true });
2777
- 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
+ }
2778
2813
  }
2779
- catch {
2780
- result = "dropped";
2814
+ if (session.hasLeft && result === "dropped") {
2815
+ result = "exited";
2781
2816
  }
2782
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
+ }
2783
2827
  return result;
2784
2828
  }
2785
- 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) {
2786
2843
  const client = await cloudClient({ requireToken: true });
2787
2844
  const baseUrl = client.baseUrl;
2788
2845
  const state = await loadCloudAuth();
@@ -2791,12 +2848,23 @@ async function runAttach(target, options) {
2791
2848
  if (!token) {
2792
2849
  throw new Error("Not logged in to Rudder Cloud. Run `rudder login` first.");
2793
2850
  }
2794
- const wsUrl = baseUrl.replace(/^http/, "ws").replace(/\/$/, "")
2795
- + `/api/rudder/${target.kind}/${encodeURIComponent(target.id)}/attach`;
2796
2851
  const stdin = process.stdin;
2797
2852
  const stdout = process.stdout;
2798
2853
  const probeMode = Boolean(options.latencyProbe);
2799
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)}`;
2800
2868
  return await new Promise((resolve, reject) => {
2801
2869
  const socket = new WebSocket(wsUrl, {
2802
2870
  headers: { authorization: `Bearer ${token}` },
@@ -2809,6 +2877,8 @@ async function runAttach(target, options) {
2809
2877
  // are bounded (~100KB worst-case repaint), so the bandwidth cost of
2810
2878
  // skipping compression is negligible next to the latency it removes.
2811
2879
  perMessageDeflate: false,
2880
+ // A connect into a dead network can otherwise sit for minutes.
2881
+ handshakeTimeout: ATTACH_HANDSHAKE_TIMEOUT_MS,
2812
2882
  });
2813
2883
  socket.binaryType = "nodebuffer";
2814
2884
  // Every deliberate close goes through socket.close() from this side; any
@@ -2821,39 +2891,57 @@ async function runAttach(target, options) {
2821
2891
  });
2822
2892
  let opened = false;
2823
2893
  let cleaned = false;
2824
- let firstFrameRendered = false;
2825
- 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";
2826
2898
  let hasControl = true;
2827
- let readOnlyNoticeShown = false;
2828
2899
  let result = "failed";
2829
- const splashAllowed = isInteractive && !options.json && !options.quietBanner;
2900
+ const splashAllowed = isInteractive && !options.json && !options.quietBanner && !resuming;
2830
2901
  const splash = splashAllowed ? new AttachSplash(stdout, target.label) : null;
2902
+ // Predictive local echo (src/predict.ts): typed characters appear at the
2903
+ // caret at once and the cloud's frame confirms them. RUDDER_PREDICT=0
2904
+ // turns the drawing off; the model stays, the reconnect badge uses it.
2905
+ const predictor = session.predictor;
2831
2906
  const sendResize = () => {
2832
2907
  if (socket.readyState !== WebSocket.OPEN) {
2833
2908
  return;
2834
2909
  }
2835
2910
  const cols = stdout.columns ?? 120;
2836
2911
  const rows = stdout.rows ?? 32;
2912
+ predictor?.resize(cols, rows);
2837
2913
  socket.send(JSON.stringify({ type: "resize", cols, rows }));
2838
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
+ };
2839
2929
  let lastCtrlC = 0;
2840
2930
  const onStdin = (chunk) => {
2931
+ const buffer = typeof chunk === "string" ? Buffer.from(chunk, "utf8") : chunk;
2841
2932
  if (socket.readyState !== WebSocket.OPEN) {
2842
2933
  return;
2843
2934
  }
2844
- const buffer = typeof chunk === "string" ? Buffer.from(chunk, "utf8") : chunk;
2845
- const hasCtrlC = containsCtrlCInput(buffer);
2846
- // Once the relay says the worker is gone, Ctrl+C means "leave this local
2847
- // attach". Forwarding/buffering it would kill the next dashboard as soon
2848
- // as the persistent machine reconnects.
2849
- 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) {
2850
2940
  try {
2851
- socket.close(1000, "client-cancel-disconnected");
2941
+ socket.close(1000, "client-cancel");
2852
2942
  }
2853
2943
  catch { /* ignore */ }
2854
- cleanup();
2855
- process.stderr.write("\nDetached from disconnected cloud worker.\n");
2856
- resolve("exited");
2944
+ finish("exited", "\nCancelled.\n");
2857
2945
  return;
2858
2946
  }
2859
2947
  // An observer must always be able to leave without sending an interrupt
@@ -2863,33 +2951,23 @@ async function runAttach(target, options) {
2863
2951
  socket.close(1000, "read-only-client-cancel");
2864
2952
  }
2865
2953
  catch { /* ignore */ }
2866
- cleanup();
2867
- process.stderr.write("\nDetached read-only cloud attachment.\n");
2868
- resolve("exited");
2954
+ finish("exited", "\nDetached read-only cloud attachment.\n");
2869
2955
  return;
2870
2956
  }
2871
2957
  if (!hasControl) {
2872
- if (!readOnlyNoticeShown) {
2873
- process.stderr.write("\nThis cloud attachment is read-only while another terminal controls the session.\n");
2874
- readOnlyNoticeShown = true;
2875
- }
2958
+ session.showStatus("Read-only · another terminal has control");
2876
2959
  return;
2877
2960
  }
2878
- // While the loading splash is up (no remote frame has rendered yet),
2879
- // Ctrl+C should cancel the local attach instead of being forwarded to
2880
- // a remote dashboard the user can't see.
2881
- if (hasCtrlC && !firstFrameRendered) {
2882
- try {
2883
- socket.close(1000, "client-cancel");
2884
- }
2885
- catch { /* ignore */ }
2886
- cleanup();
2887
- if (!opened) {
2888
- reject(new Error("Cloud attach cancelled"));
2889
- }
2890
- else {
2891
- process.stderr.write("\nCancelled.\n");
2892
- 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");
2893
2971
  }
2894
2972
  return;
2895
2973
  }
@@ -2899,24 +2977,24 @@ async function runAttach(target, options) {
2899
2977
  if (hasCtrlC && firstFrameRendered) {
2900
2978
  const now = Date.now();
2901
2979
  if (now - lastCtrlC < 2000) {
2902
- process.stderr.write("\nForce-exiting local attach (press Ctrl+C again to re-enter).\n");
2903
2980
  try {
2904
2981
  socket.close(1000, "client-force-quit");
2905
2982
  }
2906
2983
  catch { /* ignore */ }
2907
- cleanup();
2908
- resolve("exited");
2984
+ finish("exited", "\nForce-exiting local attach (press Ctrl+C again to re-enter).\n");
2909
2985
  return;
2910
2986
  }
2911
2987
  lastCtrlC = now;
2912
2988
  }
2913
2989
  socket.send(buffer, { binary: true });
2990
+ predictor?.onInput(buffer);
2914
2991
  };
2915
2992
  const onResize = () => {
2916
2993
  if (hasControl) {
2917
2994
  sendResize();
2918
2995
  }
2919
2996
  splash?.redraw();
2997
+ session.noteResize();
2920
2998
  };
2921
2999
  // Liveness. A laptop that slept leaves this socket half-open: nothing
2922
3000
  // errors, the screen just freezes, and the relay has meanwhile dropped us
@@ -2925,7 +3003,14 @@ async function runAttach(target, options) {
2925
3003
  // as a dropped connection, which attachWithReconnect reopens.
2926
3004
  let lastHeard = Date.now();
2927
3005
  let lastTick = Date.now();
2928
- socket.on("pong", () => { lastHeard = Date.now(); });
3006
+ let pingSentAt = 0;
3007
+ socket.on("pong", () => {
3008
+ lastHeard = Date.now();
3009
+ if (pingSentAt) {
3010
+ predictor?.noteRtt(Date.now() - pingSentAt);
3011
+ pingSentAt = 0;
3012
+ }
3013
+ });
2929
3014
  socket.on("message", () => { lastHeard = Date.now(); });
2930
3015
  const liveness = setInterval(() => {
2931
3016
  const now = Date.now();
@@ -2940,76 +3025,53 @@ async function runAttach(target, options) {
2940
3025
  }
2941
3026
  try {
2942
3027
  socket.ping();
3028
+ pingSentAt = Date.now();
2943
3029
  }
2944
3030
  catch {
2945
3031
  socket.terminate();
2946
3032
  }
2947
3033
  }, ATTACH_PING_MS);
2948
3034
  liveness.unref?.();
2949
- 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) => {
2950
3049
  if (cleaned) {
2951
3050
  return;
2952
3051
  }
2953
3052
  cleaned = true;
3053
+ offLeave();
2954
3054
  clearInterval(liveness);
2955
3055
  stdin.off("data", onStdin);
2956
3056
  stdout.off("resize", onResize);
2957
3057
  process.off("SIGINT", onSigint);
2958
- splash?.dispose();
2959
- if (isInteractive && stdin.isTTY) {
2960
- // Hand the terminal back the way we found it: pop the keyboard flags we
2961
- // pushed, then disable local mouse capture before leaving raw mode so the
2962
- // user's shell prompt does not get spammed with mouse SGR bytes.
2963
- try {
2964
- stdout.write(KITTY_KEYBOARD_POP);
2965
- }
2966
- catch {
2967
- // ignore
2968
- }
2969
- try {
2970
- stdout.write(KITTY_KEYBOARD_POP);
2971
- stdout.write("\x1b[?1006l\x1b[?1002l\x1b[?1000l");
2972
- }
2973
- catch {
2974
- // ignore
2975
- }
2976
- // Release the tab title so the user's shell prompt can rewrite it.
2977
- try {
2978
- stdout.write("\x1b]0;\x07");
2979
- }
2980
- catch {
2981
- // ignore
2982
- }
2983
- try {
2984
- stdin.setRawMode(false);
2985
- }
2986
- catch {
2987
- // ignore
2988
- }
3058
+ splash?.stop();
3059
+ if (keepTerminal) {
3060
+ session.enterGap();
2989
3061
  }
2990
- if (splashAllowed) {
2991
- // We held the local terminal in the alt-screen buffer for the entire
2992
- // attach session (see AttachSplash.handoff). Restore the main buffer
2993
- // and cursor now so the user is dropped back to their shell prompt
2994
- // instead of a blank alt-screen with the dashboard frozen on it.
2995
- try {
2996
- stdout.write("\x1b[?1049l\x1b[?25h");
2997
- }
2998
- catch {
2999
- // ignore
3000
- }
3062
+ else {
3063
+ session.release();
3001
3064
  }
3002
- stdin.pause();
3003
3065
  };
3004
3066
  const onSigint = () => {
3005
3067
  // Belt-and-suspenders: if raw mode failed for any reason, Node still
3006
3068
  // sees SIGINT. Close cleanly so the user never gets a frozen terminal.
3007
- process.stderr.write("\nReceived SIGINT, closing cloud attach.\n");
3008
3069
  try {
3009
3070
  socket.close(1000, "sigint");
3010
3071
  }
3011
3072
  catch { /* ignore */ }
3012
- cleanup();
3073
+ cleanup(false);
3074
+ process.stderr.write("\nReceived SIGINT, closing cloud attach.\n");
3013
3075
  if (!opened) {
3014
3076
  reject(new Error("Cloud attach cancelled"));
3015
3077
  }
@@ -3105,6 +3167,23 @@ async function runAttach(target, options) {
3105
3167
  };
3106
3168
  socket.on("open", () => {
3107
3169
  opened = true;
3170
+ session.connections += 1;
3171
+ // From here this connection reads the keys; the gap hands them over.
3172
+ session.exitGap();
3173
+ // Measure the round trip at once: predictive echo waits on it.
3174
+ try {
3175
+ socket.ping();
3176
+ pingSentAt = Date.now();
3177
+ }
3178
+ catch {
3179
+ // the liveness timer retries
3180
+ }
3181
+ if (probeMode) {
3182
+ // An idle dashboard sends nothing; a resize makes it repaint, which is
3183
+ // the first frame the probe waits for.
3184
+ socket.send(JSON.stringify({ type: "resize", cols: 119, rows: 32 }));
3185
+ setTimeout(() => socket.send(JSON.stringify({ type: "resize", cols: 120, rows: 32 })), 200);
3186
+ }
3108
3187
  // Disable Nagle on the underlying TCP socket so single keystrokes don't
3109
3188
  // sit in the send buffer waiting for piggyback ACKs (~40ms savings per
3110
3189
  // keypress on the WAN).
@@ -3117,48 +3196,35 @@ async function runAttach(target, options) {
3117
3196
  }
3118
3197
  // Label this terminal tab so the user can find the right rudder cloud
3119
3198
  // session at a glance instead of squinting at a row of "ghostty" tabs.
3120
- try {
3121
- 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
+ }
3122
3206
  }
3123
- catch {
3124
- // 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();
3125
3212
  }
3126
3213
  if (splash) {
3127
3214
  splash.start();
3128
3215
  splash.setStatus(`Booting cloud workspace · ${target.label}`);
3129
3216
  }
3130
- else if (!options.json && !options.quietBanner) {
3217
+ else if (!options.json && !options.quietBanner && !resuming) {
3131
3218
  const tail = isInteractive ? " (Ctrl+C sends to remote; close this pane to detach)" : "";
3132
3219
  process.stderr.write(`Attached to ${target.label}${tail}\n`);
3133
3220
  }
3134
3221
  sendResize();
3135
- if (isInteractive) {
3136
- try {
3137
- stdin.setRawMode(true);
3138
- }
3139
- catch {
3140
- // ignore
3141
- }
3142
- // Enable mouse tracking on the LOCAL terminal so scroll/click events
3143
- // get forwarded into stdin. The remote rudder TUI also emits these
3144
- // enable sequences, but they arrive as PTY output bytes and the local
3145
- // terminal does not interpret output bytes as mode toggles. We have
3146
- // to enable mouse capture locally too.
3147
- try {
3148
- stdout.write("\x1b[?1000h\x1b[?1002h\x1b[?1006h");
3149
- }
3150
- catch {
3151
- // ignore
3152
- }
3153
- // Ask the local terminal for the kitty keyboard protocol on the remote's
3154
- // behalf, so real modifiers reach the remote dashboard.
3155
- try {
3156
- stdout.write(KITTY_KEYBOARD_PUSH);
3157
- }
3158
- catch {
3159
- // ignore
3160
- }
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 });
3161
3226
  }
3227
+ requestRepaint();
3162
3228
  if (!probeMode) {
3163
3229
  stdin.resume();
3164
3230
  stdin.on("data", onStdin);
@@ -3200,6 +3266,14 @@ async function runAttach(target, options) {
3200
3266
  probeEchoWaiter?.(performance.now());
3201
3267
  return;
3202
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
+ }
3203
3277
  if (!firstFrameRendered) {
3204
3278
  firstFrameRendered = true;
3205
3279
  splash?.handoff();
@@ -3211,6 +3285,7 @@ async function runAttach(target, options) {
3211
3285
  setTimeout(sendResize, 150);
3212
3286
  }
3213
3287
  stdout.write(data);
3288
+ predictor?.onServerData(data);
3214
3289
  return;
3215
3290
  }
3216
3291
  const text = Buffer.isBuffer(data)
@@ -3221,18 +3296,20 @@ async function runAttach(target, options) {
3221
3296
  handleControlText(text);
3222
3297
  });
3223
3298
  socket.on("close", (code, reason) => {
3224
- 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));
3225
3302
  if (!opened) {
3226
3303
  const reasonText = reason && reason.length ? reason.toString("utf8") : "";
3227
3304
  reject(new Error(`Cloud attach failed (code ${code}${reasonText ? `: ${reasonText}` : ""})`));
3228
3305
  return;
3229
3306
  }
3230
3307
  // 4410: the workspace was deleted; nothing to come back to.
3231
- resolve(!closedByUs && code !== 4410 && code !== 1000 ? "dropped" : result);
3308
+ resolve(dropped ? "dropped" : result);
3232
3309
  });
3233
3310
  socket.on("error", (err) => {
3234
3311
  if (!opened) {
3235
- cleanup();
3312
+ cleanup(reconnects && session.connections > 0);
3236
3313
  reject(new Error(`Cloud attach failed: ${err.message}`));
3237
3314
  }
3238
3315
  });
@@ -3262,63 +3339,74 @@ async function runAttach(target, options) {
3262
3339
  }
3263
3340
  return;
3264
3341
  }
3265
- if (message.type === "status") {
3266
- if (message.state === "worker-connected") {
3267
- 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();
3268
3360
  }
3269
- else if (message.state === "worker-disconnected" || message.state === "worker-waiting") {
3270
- 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");
3271
3368
  }
3272
- if (message.control === "read-only" || message.state === "read-only") {
3273
- 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}`);
3274
3378
  }
3275
- else if (message.control === "active" || message.state === "controller-promoted") {
3276
- const promoted = !hasControl;
3277
- hasControl = true;
3278
- if (promoted || message.state === "controller-promoted") {
3279
- sendResize();
3280
- }
3379
+ else if (message.state === "worker-connected") {
3380
+ splash.setStatus(`Cloud worker connected · ${target.label}`);
3381
+ sendResize();
3382
+ setTimeout(sendResize, 150);
3281
3383
  }
3282
- // If the worker dies before we've ever seen a remote frame, the
3283
- // session is effectively dead. Don't sit on a splash spinner pretending
3284
- // it'll come back; bail.
3285
- if (message.state === "worker-disconnected" && !firstFrameRendered) {
3286
- splash?.dispose();
3287
- try {
3288
- socket.close(1000, "worker-gone");
3289
- }
3290
- catch { /* ignore */ }
3291
- cleanup();
3292
- if (!opened) {
3293
- reject(new Error("Cloud worker exited before the dashboard started"));
3294
- }
3295
- else {
3296
- process.stderr.write("\nCloud worker exited before the dashboard started.\n");
3297
- result = "failed";
3298
- resolve(result);
3299
- }
3300
- return;
3384
+ else if (message.state === "input-buffered") {
3385
+ splash.setStatus(`Reconnecting cloud input · ${target.label}`);
3301
3386
  }
3302
- if (splash && !firstFrameRendered) {
3303
- if (message.state === "worker-waiting") {
3304
- splash.setStatus(`Waiting for cloud worker · ${target.label}`);
3305
- }
3306
- else if (message.state === "worker-connected") {
3307
- splash.setStatus(`Cloud worker connected · ${target.label}`);
3308
- sendResize();
3309
- setTimeout(sendResize, 150);
3310
- }
3311
- else if (message.state === "input-buffered") {
3312
- splash.setStatus(`Reconnecting cloud input · ${target.label}`);
3313
- }
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();
3314
3405
  }
3315
- else if (!options.json && !options.quietBanner) {
3316
- if (message.state === "worker-disconnected") {
3317
- process.stderr.write("\nCloud worker disconnected; waiting for reconnect...\n");
3318
- }
3319
- else if (message.state === "worker-connected") {
3320
- process.stderr.write("Cloud worker connected.\n");
3321
- }
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();
3322
3410
  }
3323
3411
  }
3324
3412
  }
@@ -3337,12 +3425,14 @@ class AttachSplash {
3337
3425
  this.label = label;
3338
3426
  this.status = `Connecting to ${label}`;
3339
3427
  }
3428
+ // The alternate screen is AttachSession's (held for the whole attach,
3429
+ // reconnects included); the splash only draws inside it.
3340
3430
  start() {
3341
3431
  if (this.active) {
3342
3432
  return;
3343
3433
  }
3344
3434
  this.active = true;
3345
- this.stdout.write("\x1b[?1049h\x1b[?25l");
3435
+ this.stdout.write("\x1b[?25l");
3346
3436
  this.redraw();
3347
3437
  this.timer = setInterval(() => {
3348
3438
  this.frame = (this.frame + 1) % AttachSplash.FRAMES.length;
@@ -3381,25 +3471,14 @@ class AttachSplash {
3381
3471
  return;
3382
3472
  }
3383
3473
  this.stop();
3384
- // Stay in the alt-screen buffer for the rest of the attach session. The
3385
- // remote dashboard will render into this same buffer, and runAttach's
3386
- // cleanup() will exit the alt screen on shutdown. Wipe the splash content
3387
- // first so the remote's partial frames don't render on top of leftover
3388
- // spinner text (and so any tiny window before the remote's first frame
3389
- // 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.
3390
3476
  this.stdout.write("\x1b[2J\x1b[H\x1b[?25h");
3391
3477
  }
3392
- dispose() {
3478
+ stop() {
3393
3479
  if (!this.active) {
3394
3480
  return;
3395
3481
  }
3396
- this.stop();
3397
- // dispose() is the abort path (worker died, user cancelled before any
3398
- // remote frame). Leave the alt screen so the user is dropped back to their
3399
- // shell prompt instead of staring at a frozen spinner buffer.
3400
- this.stdout.write("\x1b[?1049l\x1b[?25h");
3401
- }
3402
- stop() {
3403
3482
  this.active = false;
3404
3483
  if (this.timer) {
3405
3484
  clearInterval(this.timer);
@@ -3432,7 +3511,7 @@ async function maybeAutoAttach(result, options) {
3432
3511
  return;
3433
3512
  }
3434
3513
  try {
3435
- await runAttach({ kind: "sail", id: sailId, label: sailId }, { ...options, quietBanner: false });
3514
+ await attachWithReconnect({ kind: "sail", id: sailId, label: sailId }, options);
3436
3515
  }
3437
3516
  catch (error) {
3438
3517
  console.warn(`Could not attach to ${sailId}: ${error instanceof Error ? error.message : String(error)}`);
@@ -3673,13 +3752,18 @@ const LAPTOP_CONFIG_FILES = [
3673
3752
  "~/.codex/config.toml",
3674
3753
  "~/.codex/AGENTS.md",
3675
3754
  "~/.codex/hooks.json",
3755
+ // Service logins agents use from scripts (Kalshi signs requests with this key).
3756
+ "~/.kalshi/config.json",
3757
+ "~/.kalshi/private_key.pem",
3676
3758
  ];
3677
3759
  async function laptopConfigFiles() {
3678
3760
  const names = [...LAPTOP_CONFIG_FILES, "~/.aws/config", "~/.aws/credentials"];
3679
- const hooksDir = path.join(os.homedir(), ".claude", "hooks");
3680
- for (const entry of await fsp.readdir(hooksDir, { withFileTypes: true }).catch(() => [])) {
3681
- if (entry.isFile()) {
3682
- 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
+ }
3683
3767
  }
3684
3768
  }
3685
3769
  const out = [];
@@ -4225,6 +4309,38 @@ async function homeCommand(args, options) {
4225
4309
  }
4226
4310
  return;
4227
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
+ }
4228
4344
  if (sub === "open") {
4229
4345
  const repo = args[1];
4230
4346
  if (!repo || !/^[\w.-]+\/[\w.-]+$/.test(repo)) {
@@ -4244,7 +4360,7 @@ async function homeCommand(args, options) {
4244
4360
  return;
4245
4361
  }
4246
4362
  if (sub !== "status") {
4247
- 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...>]");
4248
4364
  }
4249
4365
  const result = await client.request("/api/rudder/home", { method: "GET" });
4250
4366
  if (options.json) {