lunel-cli 0.1.41 → 0.1.43

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/index.js +382 -43
  2. package/package.json +2 -1
package/dist/index.js CHANGED
@@ -13,6 +13,8 @@ import { createServer, createConnection } from "net";
13
13
  import { createInterface } from "readline";
14
14
  const DEFAULT_PROXY_URL = normalizeGatewayUrl(process.env.LUNEL_PROXY_URL || "https://gateway.lunel.dev");
15
15
  const MANAGER_URL = normalizeGatewayUrl(process.env.LUNEL_MANAGER_URL || "https://manager.lunel.dev");
16
+ const CLOUD_JOB_CONFIG_PATH = "/etc/lunel/job.json";
17
+ const VM_HEARTBEAT_INTERVAL_MS = 10_000;
16
18
  const CLI_ARGS = process.argv.slice(2);
17
19
  import { createRequire } from "module";
18
20
  const __require = createRequire(import.meta.url);
@@ -33,6 +35,8 @@ const processOutputBuffers = new Map();
33
35
  let lastCpuInfo = null;
34
36
  // OpenCode client
35
37
  let opencodeClient = null;
38
+ // Tracks the most recently used OpenCode session ID so we can validate it after SSE reconnects.
39
+ let lastActiveOpenCodeSessionId = null;
36
40
  // Proxy tunnel management
37
41
  let currentSessionCode = null;
38
42
  let currentSessionPassword = null;
@@ -44,6 +48,51 @@ let activeControlWs = null;
44
48
  let activeDataWs = null;
45
49
  const activeTunnels = new Map();
46
50
  const PORT_SYNC_INTERVAL_MS = 30_000;
51
+ let cloudJobConfig = null;
52
+ let vmHeartbeatTimer = null;
53
+ async function readCloudJobConfig() {
54
+ try {
55
+ const data = await fs.readFile(CLOUD_JOB_CONFIG_PATH, "utf-8");
56
+ return JSON.parse(data);
57
+ }
58
+ catch {
59
+ return null;
60
+ }
61
+ }
62
+ function startVmHeartbeat(resumeToken) {
63
+ if (!cloudJobConfig?.session_code)
64
+ return;
65
+ if (vmHeartbeatTimer)
66
+ return;
67
+ const cfg = cloudJobConfig;
68
+ const sendHeartbeat = () => {
69
+ if (shuttingDown)
70
+ return;
71
+ fetch(`${MANAGER_URL}/v1/vm/heartbeat`, {
72
+ method: "POST",
73
+ headers: { "Content-Type": "application/json" },
74
+ body: JSON.stringify({
75
+ sandboxId: cfg.sandbox_id,
76
+ resumeToken,
77
+ sandmanUrl: cfg.sandman_url || "",
78
+ repoUrl: cfg.repo_url,
79
+ branch: cfg.branch,
80
+ vmProfile: cfg.vm_profile || "",
81
+ }),
82
+ }).catch((err) => {
83
+ if (!shuttingDown)
84
+ console.warn("[vm] heartbeat failed:", err.message);
85
+ });
86
+ };
87
+ sendHeartbeat();
88
+ vmHeartbeatTimer = setInterval(sendHeartbeat, VM_HEARTBEAT_INTERVAL_MS);
89
+ }
90
+ function stopVmHeartbeat() {
91
+ if (vmHeartbeatTimer) {
92
+ clearInterval(vmHeartbeatTimer);
93
+ vmHeartbeatTimer = null;
94
+ }
95
+ }
47
96
  const CLI_LOCAL_TCP_CONNECT_TIMEOUT_MS = 2_500;
48
97
  const PROXY_WS_CONNECT_TIMEOUT_MS = 12_000;
49
98
  const TUNNEL_SETUP_BUDGET_MS = 18_000;
@@ -781,6 +830,166 @@ async function handleGitDiscard(payload) {
781
830
  // Terminal Handlers (delegates to Rust PTY binary)
782
831
  // ============================================================================
783
832
  let dataChannel = null;
833
+ let e2eeKeyPair = null;
834
+ let e2eeCliToAppKey = null; // AES-256-GCM key, CLI→App direction
835
+ let e2eeAppToCliKey = null; // AES-256-GCM key, App→CLI direction
836
+ let e2eeActive = false;
837
+ let e2eeSentReady = false;
838
+ let e2eeGotReady = false;
839
+ let e2eeOutboundCounter = 0;
840
+ function e2eeReset() {
841
+ e2eeKeyPair = null;
842
+ e2eeCliToAppKey = null;
843
+ e2eeAppToCliKey = null;
844
+ e2eeActive = false;
845
+ e2eeSentReady = false;
846
+ e2eeGotReady = false;
847
+ e2eeOutboundCounter = 0;
848
+ }
849
+ function e2eeHandlePeerHello(peerPubkeyB64, dataWs) {
850
+ const kp = crypto.generateKeyPairSync("ec", { namedCurve: "P-256" });
851
+ e2eeKeyPair = kp;
852
+ const peerDer = Buffer.from(peerPubkeyB64, "base64url");
853
+ const peerPubKey = crypto.createPublicKey({ key: peerDer, type: "spki", format: "der" });
854
+ const sharedSecret = crypto.diffieHellman({ privateKey: kp.privateKey, publicKey: peerPubKey });
855
+ const salt = Buffer.alloc(32);
856
+ e2eeCliToAppKey = Buffer.from(crypto.hkdfSync("sha256", sharedSecret, salt, Buffer.from("lunel-cli-to-app"), 32));
857
+ e2eeAppToCliKey = Buffer.from(crypto.hkdfSync("sha256", sharedSecret, salt, Buffer.from("lunel-app-to-cli"), 32));
858
+ // Send our own pubkey so the app can derive the same shared secret
859
+ const mySpki = kp.publicKey.export({ type: "spki", format: "der" });
860
+ dataWs.send(JSON.stringify({ type: "e2ee_hello", pubkey: Buffer.from(mySpki).toString("base64url") }));
861
+ dataWs.send(JSON.stringify({ type: "e2ee_secure_ready" }));
862
+ e2eeSentReady = true;
863
+ if (e2eeGotReady) {
864
+ e2eeActive = true;
865
+ console.log("[e2ee] encryption active");
866
+ }
867
+ }
868
+ function e2eeHandlePeerReady() {
869
+ e2eeGotReady = true;
870
+ if (e2eeSentReady) {
871
+ e2eeActive = true;
872
+ console.log("[e2ee] encryption active");
873
+ }
874
+ }
875
+ function e2eeEncrypt(payload) {
876
+ if (!e2eeCliToAppKey)
877
+ throw new Error("[e2ee] CLI→App key not ready");
878
+ const nonce = Buffer.alloc(12);
879
+ nonce.writeUInt32BE(e2eeOutboundCounter++, 8);
880
+ const plain = Buffer.from(JSON.stringify(payload));
881
+ const cipher = crypto.createCipheriv("aes-256-gcm", e2eeCliToAppKey, nonce);
882
+ const ct = Buffer.concat([cipher.update(plain), cipher.final()]);
883
+ const tag = cipher.getAuthTag();
884
+ // wire: nonce(12) || ct || tag(16) — consistent with WebCrypto AES-GCM format on app side
885
+ return Buffer.concat([nonce, ct, tag]).toString("base64url");
886
+ }
887
+ function e2eeDecrypt(enc) {
888
+ if (!e2eeAppToCliKey)
889
+ throw new Error("[e2ee] App→CLI key not ready");
890
+ const buf = Buffer.from(enc, "base64url");
891
+ const nonce = buf.subarray(0, 12);
892
+ const ct = buf.subarray(12, buf.length - 16);
893
+ const tag = buf.subarray(buf.length - 16);
894
+ const decipher = crypto.createDecipheriv("aes-256-gcm", e2eeAppToCliKey, nonce);
895
+ decipher.setAuthTag(tag);
896
+ return JSON.parse(Buffer.concat([decipher.update(ct), decipher.final()]).toString("utf-8"));
897
+ }
898
+ // ============================================================================
899
+ // Replay buffer + backpressure
900
+ // ============================================================================
901
+ // Replay buffer for sequenced outbound data-channel messages
902
+ let outboundSeq = 0;
903
+ let lastSentSeq = 0; // highest seq actually flushed to the socket (not paused)
904
+ const REPLAY_BUFFER_MAX_MESSAGES = 500;
905
+ const REPLAY_BUFFER_MAX_BYTES = 10 * 1024 * 1024; // 10 MB
906
+ const replayBuffer = [];
907
+ let replayBufferBytes = 0;
908
+ // Data-channel backpressure
909
+ const DATA_CHANNEL_HIGH_WATER_BYTES = 1 * 1024 * 1024; // 1 MB — pause forwarding
910
+ const DATA_CHANNEL_LOW_WATER_BYTES = 256 * 1024; // 256 KB — resume forwarding
911
+ let dataChannelPaused = false;
912
+ let dataChannelDrainTimer = null;
913
+ function sendOnDataChannel(ws, msg) {
914
+ if (e2eeActive && e2eeCliToAppKey) {
915
+ const { payload, ...envelope } = msg;
916
+ ws.send(JSON.stringify({ ...envelope, enc: e2eeEncrypt(payload) }));
917
+ }
918
+ else {
919
+ ws.send(JSON.stringify(msg));
920
+ }
921
+ }
922
+ function sendSequenced(channel, msg) {
923
+ const seq = ++outboundSeq;
924
+ const msgWithSeq = { ...msg, seq };
925
+ const byteLen = Buffer.byteLength(JSON.stringify(msgWithSeq), "utf-8");
926
+ replayBuffer.push({ seq, msg: msgWithSeq, byteLen });
927
+ replayBufferBytes += byteLen;
928
+ while (replayBuffer.length > REPLAY_BUFFER_MAX_MESSAGES || replayBufferBytes > REPLAY_BUFFER_MAX_BYTES) {
929
+ const evicted = replayBuffer.shift();
930
+ if (evicted)
931
+ replayBufferBytes -= evicted.byteLen;
932
+ }
933
+ // Skip the actual send while backpressured — event is already in replay buffer
934
+ if (channel.readyState === WebSocket.OPEN && !dataChannelPaused) {
935
+ sendOnDataChannel(channel, msgWithSeq);
936
+ lastSentSeq = seq;
937
+ }
938
+ }
939
+ function resetReplayBuffer() {
940
+ outboundSeq = 0;
941
+ lastSentSeq = 0;
942
+ replayBuffer.length = 0;
943
+ replayBufferBytes = 0;
944
+ dataChannelPaused = false;
945
+ e2eeReset();
946
+ if (dataChannelDrainTimer) {
947
+ clearInterval(dataChannelDrainTimer);
948
+ dataChannelDrainTimer = null;
949
+ }
950
+ }
951
+ function resumeDataChannel() {
952
+ if (!dataChannelPaused)
953
+ return;
954
+ dataChannelPaused = false;
955
+ if (dataChannelDrainTimer) {
956
+ clearInterval(dataChannelDrainTimer);
957
+ dataChannelDrainTimer = null;
958
+ }
959
+ // Flush events that were buffered during the pause (re-encrypts with current key)
960
+ const toFlush = replayBuffer.filter((e) => e.seq > lastSentSeq);
961
+ if (dataChannel?.readyState === WebSocket.OPEN) {
962
+ for (const entry of toFlush) {
963
+ sendOnDataChannel(dataChannel, entry.msg);
964
+ lastSentSeq = entry.seq;
965
+ }
966
+ }
967
+ console.log(`[backpressure] data channel resumed, flushed ${toFlush.length} buffered events`);
968
+ if (activeControlWs?.readyState === WebSocket.OPEN) {
969
+ const buffered = dataChannel?.bufferedAmount ?? 0;
970
+ activeControlWs.send(JSON.stringify({ type: "data_channel_resumed", bufferedBytes: buffered }));
971
+ }
972
+ }
973
+ function checkDataChannelBackpressure() {
974
+ if (!dataChannel || dataChannel.readyState !== WebSocket.OPEN)
975
+ return;
976
+ const buffered = dataChannel.bufferedAmount ?? 0;
977
+ if (!dataChannelPaused && buffered > DATA_CHANNEL_HIGH_WATER_BYTES) {
978
+ dataChannelPaused = true;
979
+ console.warn(`[backpressure] data channel paused (${buffered} bytes buffered)`);
980
+ if (activeControlWs?.readyState === WebSocket.OPEN) {
981
+ activeControlWs.send(JSON.stringify({ type: "data_channel_paused", bufferedBytes: buffered }));
982
+ }
983
+ if (!dataChannelDrainTimer) {
984
+ dataChannelDrainTimer = setInterval(() => {
985
+ const current = dataChannel?.bufferedAmount ?? 0;
986
+ if (current < DATA_CHANNEL_LOW_WATER_BYTES || !dataChannel || dataChannel.readyState !== WebSocket.OPEN) {
987
+ resumeDataChannel();
988
+ }
989
+ }, 100);
990
+ }
991
+ }
992
+ }
784
993
  let ensurePtyBinaryPromise = null;
785
994
  function normalizeJsonWithTrailingCommas(text) {
786
995
  return text.replace(/,\s*([}\]])/g, "$1");
@@ -991,7 +1200,7 @@ async function ensurePtyProcess() {
991
1200
  scrollbackLength: event.scrollbackLength,
992
1201
  },
993
1202
  };
994
- dataChannel.send(JSON.stringify(msg));
1203
+ sendSequenced(dataChannel, msg);
995
1204
  }
996
1205
  }
997
1206
  else if (event.event === "exit") {
@@ -1004,7 +1213,7 @@ async function ensurePtyProcess() {
1004
1213
  action: "exit",
1005
1214
  payload: { terminalId: event.id, code: event.code },
1006
1215
  };
1007
- dataChannel.send(JSON.stringify(msg));
1216
+ sendSequenced(dataChannel, msg);
1008
1217
  }
1009
1218
  }
1010
1219
  else if (event.event === "error") {
@@ -1155,7 +1364,7 @@ function handleProcessesSpawn(payload) {
1155
1364
  action: "output",
1156
1365
  payload: { pid, channel, stream, data: text },
1157
1366
  };
1158
- dataChannel.send(JSON.stringify(msg));
1367
+ sendSequenced(dataChannel, msg);
1159
1368
  }
1160
1369
  };
1161
1370
  proc.stdout?.on("data", sendOutput("stdout"));
@@ -1634,6 +1843,8 @@ async function handleAiGetMessages(payload) {
1634
1843
  }
1635
1844
  async function handleAiPrompt(payload) {
1636
1845
  const sessionId = payload.sessionId;
1846
+ if (sessionId)
1847
+ lastActiveOpenCodeSessionId = sessionId;
1637
1848
  const text = payload.text;
1638
1849
  const model = payload.model;
1639
1850
  const agent = payload.agent;
@@ -1656,7 +1867,7 @@ async function handleAiPrompt(payload) {
1656
1867
  }).catch((err) => {
1657
1868
  console.error("[ai] prompt error:", err.message);
1658
1869
  if (dataChannel && dataChannel.readyState === WebSocket.OPEN) {
1659
- dataChannel.send(JSON.stringify({
1870
+ sendSequenced(dataChannel, {
1660
1871
  v: 1,
1661
1872
  id: `evt-${Date.now()}`,
1662
1873
  ns: "ai",
@@ -1665,7 +1876,7 @@ async function handleAiPrompt(payload) {
1665
1876
  type: "prompt_error",
1666
1877
  properties: { sessionId, error: err.message },
1667
1878
  },
1668
- }));
1879
+ });
1669
1880
  }
1670
1881
  });
1671
1882
  return { ack: true };
@@ -1765,43 +1976,99 @@ async function handleAiPermissionReply(payload) {
1765
1976
  return {};
1766
1977
  }
1767
1978
  // SSE event forwarding from OpenCode to mobile app
1979
+ const SSE_BACKOFF_INITIAL_MS = 500;
1980
+ const SSE_BACKOFF_CAP_MS = 30_000;
1981
+ const SSE_MAX_RETRIES = 20;
1768
1982
  async function subscribeToOpenCodeEvents(client) {
1769
- try {
1770
- const events = await client.event.subscribe();
1771
- for await (const raw of events.stream) {
1772
- // OpenCode SSE payload shapes vary across versions:
1773
- // { type, properties, ... }
1774
- // { payload: { type, properties, ... }, directory: "..." }
1775
- const parsed = raw;
1776
- const base = parsed?.payload && typeof parsed.payload === "object"
1777
- ? parsed.payload
1778
- : parsed;
1779
- if (!base || typeof base.type !== "string") {
1780
- console.warn("[sse] Dropped malformed event:", redactSensitive(JSON.stringify(parsed).substring(0, 200)));
1781
- continue;
1983
+ let attempt = 0;
1984
+ const backoffMs = (n) => {
1985
+ const base = Math.min(SSE_BACKOFF_INITIAL_MS * 2 ** n, SSE_BACKOFF_CAP_MS);
1986
+ const jitter = Math.random() * base * 0.3;
1987
+ return Math.round(base + jitter);
1988
+ };
1989
+ while (!shuttingDown) {
1990
+ try {
1991
+ // On reconnect, verify the active OpenCode session is still alive.
1992
+ // If OpenCode garbage-collected it, notify the app before resuming.
1993
+ if (attempt > 0 && lastActiveOpenCodeSessionId) {
1994
+ const checkResp = await client.session.get({ path: { id: lastActiveOpenCodeSessionId } });
1995
+ if (checkResp.error) {
1996
+ console.warn(`[sse] OpenCode session ${lastActiveOpenCodeSessionId} was garbage-collected. Notifying app.`);
1997
+ const gcSessionId = lastActiveOpenCodeSessionId;
1998
+ lastActiveOpenCodeSessionId = null;
1999
+ if (dataChannel && dataChannel.readyState === WebSocket.OPEN) {
2000
+ sendSequenced(dataChannel, {
2001
+ v: 1,
2002
+ id: `evt-${Date.now()}`,
2003
+ ns: "ai",
2004
+ action: "event",
2005
+ payload: { type: "session_gc", properties: { sessionId: gcSessionId } },
2006
+ });
2007
+ }
2008
+ }
2009
+ else {
2010
+ console.log(`[sse] Active session ${lastActiveOpenCodeSessionId} still valid.`);
2011
+ }
1782
2012
  }
1783
- console.log("[sse]", base.type);
1784
- if (dataChannel && dataChannel.readyState === WebSocket.OPEN) {
1785
- const msg = {
1786
- v: 1,
1787
- id: `evt-${Date.now()}-${Math.random().toString(36).substring(2, 6)}`,
1788
- ns: "ai",
1789
- action: "event",
1790
- payload: {
1791
- type: base.type,
1792
- properties: base.properties || {},
1793
- },
1794
- };
1795
- dataChannel.send(JSON.stringify(msg));
2013
+ const events = await client.event.subscribe();
2014
+ if (attempt > 0) {
2015
+ console.log(`[sse] reconnected after ${attempt} attempt(s)`);
1796
2016
  }
2017
+ attempt = 0; // reset on successful connect
2018
+ for await (const raw of events.stream) {
2019
+ if (shuttingDown)
2020
+ return;
2021
+ // OpenCode SSE payload shapes vary across versions:
2022
+ // { type, properties, ... }
2023
+ // { payload: { type, properties, ... }, directory: "..." }
2024
+ const parsed = raw;
2025
+ const base = parsed?.payload && typeof parsed.payload === "object"
2026
+ ? parsed.payload
2027
+ : parsed;
2028
+ if (!base || typeof base.type !== "string") {
2029
+ console.warn("[sse] Dropped malformed event:", redactSensitive(JSON.stringify(parsed).substring(0, 200)));
2030
+ continue;
2031
+ }
2032
+ console.log("[sse]", base.type);
2033
+ if (dataChannel && dataChannel.readyState === WebSocket.OPEN) {
2034
+ const msg = {
2035
+ v: 1,
2036
+ id: `evt-${Date.now()}`,
2037
+ ns: "ai",
2038
+ action: "event",
2039
+ payload: {
2040
+ type: base.type,
2041
+ properties: base.properties || {},
2042
+ },
2043
+ };
2044
+ sendSequenced(dataChannel, msg);
2045
+ checkDataChannelBackpressure();
2046
+ }
2047
+ }
2048
+ // Stream ended normally — reconnect immediately (attempt stays 0 for normal end)
2049
+ console.log("[sse] Event stream ended, reconnecting...");
2050
+ }
2051
+ catch (err) {
2052
+ if (shuttingDown)
2053
+ return;
2054
+ attempt++;
2055
+ const delay = backoffMs(attempt - 1);
2056
+ console.error(`[sse] Stream error (attempt ${attempt}/${SSE_MAX_RETRIES}): ${err.message}. Retrying in ${delay}ms`);
2057
+ if (attempt >= SSE_MAX_RETRIES) {
2058
+ console.error("[sse] Max retries reached. Sending error event to app and giving up.");
2059
+ if (dataChannel && dataChannel.readyState === WebSocket.OPEN) {
2060
+ sendSequenced(dataChannel, {
2061
+ v: 1,
2062
+ id: `evt-${Date.now()}`,
2063
+ ns: "ai",
2064
+ action: "event",
2065
+ payload: { type: "sse_dead", properties: { error: err.message, attempts: attempt } },
2066
+ });
2067
+ }
2068
+ return;
2069
+ }
2070
+ await new Promise((resolve) => setTimeout(resolve, delay));
1797
2071
  }
1798
- // Stream ended normally — reconnect
1799
- console.log("[sse] Event stream ended, reconnecting...");
1800
- setTimeout(() => subscribeToOpenCodeEvents(client), 1000);
1801
- }
1802
- catch (err) {
1803
- console.error("[sse] Event stream error:", err.message);
1804
- setTimeout(() => subscribeToOpenCodeEvents(client), 3000);
1805
2072
  }
1806
2073
  }
1807
2074
  // Proxy Handlers
@@ -2427,7 +2694,13 @@ async function processMessage(message) {
2427
2694
  }
2428
2695
  }
2429
2696
  function sendResponseOnData(response, dataWs) {
2430
- dataWs.send(JSON.stringify(response));
2697
+ if (e2eeActive && e2eeCliToAppKey) {
2698
+ const { payload, ...envelope } = response;
2699
+ dataWs.send(JSON.stringify({ ...envelope, enc: e2eeEncrypt(payload) }));
2700
+ }
2701
+ else {
2702
+ dataWs.send(JSON.stringify(response));
2703
+ }
2431
2704
  }
2432
2705
  function normalizeGatewayUrl(input) {
2433
2706
  const raw = input.trim();
@@ -2461,6 +2734,13 @@ async function createSessionFromManager() {
2461
2734
  }
2462
2735
  return (await response.json());
2463
2736
  }
2737
+ async function connectToCloudSession(sessionCode) {
2738
+ const response = await fetch(`${MANAGER_URL}/v1/session/${sessionCode}`);
2739
+ if (!response.ok) {
2740
+ throw new Error(`Failed to look up cloud session ${sessionCode}: ${response.status}`);
2741
+ }
2742
+ return (await response.json());
2743
+ }
2464
2744
  function displayQR(primaryGateway, backupGateway, code) {
2465
2745
  console.log("\n");
2466
2746
  qrcode.generate(code, { small: true }, (qr) => {
@@ -2498,6 +2778,7 @@ function buildWsUrl(gatewayUrl, role, channel) {
2498
2778
  function gracefulShutdown() {
2499
2779
  shuttingDown = true;
2500
2780
  console.log("\nShutting down...");
2781
+ stopVmHeartbeat();
2501
2782
  stopPortSync();
2502
2783
  if (ptyProcess) {
2503
2784
  ptyProcess.kill();
@@ -2575,6 +2856,8 @@ async function connectWebSocket() {
2575
2856
  if (message.type === "connected")
2576
2857
  return;
2577
2858
  if (message.type === "session_password" && message.password) {
2859
+ if (!currentSessionPassword)
2860
+ resetReplayBuffer(); // new session
2578
2861
  currentSessionPassword = message.password;
2579
2862
  console.log("[session] received reconnect password");
2580
2863
  return;
@@ -2640,9 +2923,41 @@ async function connectWebSocket() {
2640
2923
  });
2641
2924
  dataWs.on("message", async (data) => {
2642
2925
  try {
2643
- const message = JSON.parse(data.toString());
2644
- if (message.type === "connected")
2926
+ const raw = JSON.parse(data.toString());
2927
+ if (raw.type === "connected")
2928
+ return;
2929
+ // E2EE handshake messages (always plaintext)
2930
+ if (raw.type === "e2ee_hello" && typeof raw.pubkey === "string") {
2931
+ e2eeHandlePeerHello(raw.pubkey, dataWs);
2645
2932
  return;
2933
+ }
2934
+ if (raw.type === "e2ee_secure_ready") {
2935
+ e2eeHandlePeerReady();
2936
+ return;
2937
+ }
2938
+ // Reconnect request: reset E2EE so fresh handshake happens, then replay
2939
+ if (raw.ns === "system" && raw.action === "reconnect") {
2940
+ e2eeReset();
2941
+ const lastSeq = Number(raw.payload?.lastSeq ?? 0);
2942
+ const toReplay = replayBuffer.filter((e) => e.seq > lastSeq);
2943
+ console.log(`[replay] replaying ${toReplay.length} messages after seq ${lastSeq}`);
2944
+ // Replay without encryption — E2EE handshake hasn't completed yet
2945
+ for (const entry of toReplay)
2946
+ dataWs.send(JSON.stringify(entry.msg));
2947
+ return;
2948
+ }
2949
+ // Decrypt payload if E2EE is active
2950
+ let message = raw;
2951
+ if (e2eeActive && typeof raw.enc === "string") {
2952
+ try {
2953
+ message = { ...raw, payload: e2eeDecrypt(raw.enc) };
2954
+ delete message.enc;
2955
+ }
2956
+ catch (decErr) {
2957
+ console.error("[e2ee] decryption failed:", decErr.message);
2958
+ return;
2959
+ }
2960
+ }
2646
2961
  if (isProtocolResponse(message)) {
2647
2962
  // Ignore server/app responses forwarded over WS; CLI only processes requests.
2648
2963
  return;
@@ -2659,6 +2974,12 @@ async function connectWebSocket() {
2659
2974
  }
2660
2975
  });
2661
2976
  dataWs.on("close", (code, reason) => {
2977
+ // Reset backpressure state so reconnect starts fresh
2978
+ dataChannelPaused = false;
2979
+ if (dataChannelDrainTimer) {
2980
+ clearInterval(dataChannelDrainTimer);
2981
+ dataChannelDrainTimer = null;
2982
+ }
2662
2983
  if (!settled) {
2663
2984
  failConnection(`data close before ready (${code}: ${reason.toString()})`);
2664
2985
  return;
@@ -2710,6 +3031,11 @@ async function main() {
2710
3031
  if (EXTRA_PORTS.length > 0) {
2711
3032
  console.log(`Extra ports enabled: ${EXTRA_PORTS.join(", ")}`);
2712
3033
  }
3034
+ // Detect cloud VM mode
3035
+ cloudJobConfig = await readCloudJobConfig();
3036
+ if (cloudJobConfig?.session_code) {
3037
+ console.log(`[cloud] Running in VM mode (sandbox: ${cloudJobConfig.sandbox_id})`);
3038
+ }
2713
3039
  try {
2714
3040
  console.log("Checking PTY runtime...");
2715
3041
  await ensurePtyBinaryReady();
@@ -2740,13 +3066,26 @@ async function main() {
2740
3066
  console.log("OpenCode ready.\n");
2741
3067
  // Subscribe to OpenCode events
2742
3068
  subscribeToOpenCodeEvents(client);
2743
- const session = await createSessionFromManager();
3069
+ let session;
3070
+ if (cloudJobConfig?.session_code) {
3071
+ // Cloud mode: connect to an existing session assigned by the manager
3072
+ session = await connectToCloudSession(cloudJobConfig.session_code);
3073
+ console.log(`[cloud] Connected to session ${session.code} via ${session.primary}`);
3074
+ }
3075
+ else {
3076
+ // Public mode: create a new session and show QR code
3077
+ session = await createSessionFromManager();
3078
+ displayQR(normalizeGatewayUrl(session.primary), session.backup ? normalizeGatewayUrl(session.backup) : null, session.code);
3079
+ }
2744
3080
  currentPrimaryGateway = normalizeGatewayUrl(session.primary);
2745
3081
  currentBackupGateway = session.backup ? normalizeGatewayUrl(session.backup) : null;
2746
3082
  currentSessionPassword = session.password;
2747
3083
  activeGatewayUrl = currentPrimaryGateway;
2748
3084
  currentSessionCode = session.code;
2749
- displayQR(currentPrimaryGateway, currentBackupGateway, session.code);
3085
+ // Start VM heartbeat in cloud mode (session.password = resumeToken)
3086
+ if (cloudJobConfig?.session_code) {
3087
+ startVmHeartbeat(session.password);
3088
+ }
2750
3089
  await connectWebSocket();
2751
3090
  }
2752
3091
  catch (error) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lunel-cli",
3
- "version": "0.1.41",
3
+ "version": "0.1.43",
4
4
  "author": [
5
5
  {
6
6
  "name": "Soham Bharambe",
@@ -32,6 +32,7 @@
32
32
  "ws": "^8.18.0"
33
33
  },
34
34
  "devDependencies": {
35
+ "@types/minimatch": "^5.1.2",
35
36
  "@types/node": "^20.0.0",
36
37
  "@types/qrcode-terminal": "^0.12.2",
37
38
  "@types/ws": "^8.5.13",