very-happy-cli 0.2.75 → 0.2.77

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 (31) hide show
  1. package/dist/{AcpBackend-5CPQMP3H.mjs → AcpBackend-BFFC77vV.mjs} +5 -2
  2. package/dist/{AcpBackend-B0qCm-bY.cjs → AcpBackend-CsLnh1So.cjs} +5 -2
  3. package/dist/{AcpSessionManager-B4M0y5fI.cjs → AcpSessionManager--CixPN-p.cjs} +1 -1
  4. package/dist/{AcpSessionManager-BTmJyKDO.mjs → AcpSessionManager-CU5vsxgF.mjs} +1 -1
  5. package/dist/{config-DbY2R-H7.mjs → config-CRFTYuTN.mjs} +2 -2
  6. package/dist/{config-DBIfKaNJ.cjs → config-pv17N6Kg.cjs} +2 -2
  7. package/dist/{index-CJaqyq4z.mjs → index-BkvUwyIU.mjs} +8 -5
  8. package/dist/{index-CqxywXyf.cjs → index-C1OnwMx0.cjs} +7 -4
  9. package/dist/{index-Cq30c63l.mjs → index-DOb57EGb.mjs} +16 -12
  10. package/dist/{index-edEH95Mf.cjs → index-Dcv1RKe8.cjs} +19 -15
  11. package/dist/index.cjs +2 -2
  12. package/dist/index.mjs +2 -2
  13. package/dist/{installTerminalHooks-8TUdSFCY.mjs → installTerminalHooks-IXvZMGJq.mjs} +2 -2
  14. package/dist/{installTerminalHooks-BvQbDFcE.cjs → installTerminalHooks-YyT3fv6w.cjs} +2 -2
  15. package/dist/lib.cjs +1 -1
  16. package/dist/lib.d.cts +23 -1
  17. package/dist/lib.d.mts +23 -1
  18. package/dist/lib.mjs +1 -1
  19. package/dist/{mcp-B4UxtBKQ.mjs → mcp-B-GdhlKD.mjs} +2 -2
  20. package/dist/{mcp-D12X7XKG.cjs → mcp-CsKnmeE5.cjs} +2 -2
  21. package/dist/{runGemini-BD8OGMQr.mjs → runGemini-D0lddF43.mjs} +5 -4
  22. package/dist/{runGemini-LIck5ard.cjs → runGemini-DuUdmlVZ.cjs} +5 -4
  23. package/dist/{runOpenClaw-ENAgQy1E.mjs → runOpenClaw-BiwwYnYf.mjs} +15 -3
  24. package/dist/{runOpenClaw-B5XNV1zZ.cjs → runOpenClaw-DEMCYSoD.cjs} +15 -3
  25. package/dist/{send-BVbiwtrQ.mjs → send-DM8oqJFp.mjs} +2 -2
  26. package/dist/{send-o4xljyoV.cjs → send-DijSXAMp.cjs} +2 -2
  27. package/dist/{spawn-DTA6f9TE.cjs → spawn-De3b5Hsu.cjs} +2 -2
  28. package/dist/{spawn-BmqqHUYI.mjs → spawn-nISXbZD5.mjs} +2 -2
  29. package/dist/{types-Dj9JEGDh.mjs → types-D2qSKjsO.mjs} +400 -79
  30. package/dist/{types-DEre2zxO.cjs → types-NwtcBgfS.cjs} +401 -80
  31. package/package.json +7 -7
@@ -29,7 +29,7 @@ import { SandboxManager } from '@anthropic-ai/sandbox-runtime';
29
29
  import { Expo } from 'expo-server-sdk';
30
30
 
31
31
  var name = "very-happy-cli";
32
- var version = "0.2.75";
32
+ var version = "0.2.77";
33
33
  var description = "Remote multi-agent workspace CLI with deep Claude integration, native terminal streaming, and self-hosting.";
34
34
  var author = "Kirill Dubovitskiy";
35
35
  var contributors = [
@@ -142,12 +142,12 @@ var dependencies = {
142
142
  zod: "^4.0.0"
143
143
  };
144
144
  var optionalDependencies = {
145
- "very-happy-tools-arm64-darwin": "0.2.75",
146
- "very-happy-tools-x64-darwin": "0.2.75",
147
- "very-happy-tools-arm64-linux": "0.2.75",
148
- "very-happy-tools-x64-linux": "0.2.75",
149
- "very-happy-tools-arm64-win32": "0.2.75",
150
- "very-happy-tools-x64-win32": "0.2.75"
145
+ "very-happy-tools-arm64-darwin": "0.2.77",
146
+ "very-happy-tools-x64-darwin": "0.2.77",
147
+ "very-happy-tools-arm64-linux": "0.2.77",
148
+ "very-happy-tools-x64-linux": "0.2.77",
149
+ "very-happy-tools-arm64-win32": "0.2.77",
150
+ "very-happy-tools-x64-win32": "0.2.77"
151
151
  };
152
152
  var devDependencies = {
153
153
  "@slopus/happy-wire": "workspace:*",
@@ -896,6 +896,15 @@ z$1.object({
896
896
  region: z$1.string().min(1).max(64),
897
897
  version: z$1.string().min(1).max(64)
898
898
  });
899
+ const ReleaseSlotSchema = z$1.enum(["blue", "green"]);
900
+ const ReleaseDrainNoticeSchema = z$1.object({
901
+ epoch: z$1.string().regex(/^[A-Za-z0-9._-]{8,128}$/),
902
+ fromRelease: z$1.string().regex(/^[0-9a-f]{40}$/),
903
+ toRelease: z$1.string().regex(/^[0-9a-f]{40}$/),
904
+ candidateSlot: ReleaseSlotSchema,
905
+ deadline: z$1.number().int().positive(),
906
+ mode: z$1.literal("make-before-break")
907
+ });
899
908
 
900
909
  z$1.object({
901
910
  host: z$1.string(),
@@ -1359,6 +1368,56 @@ class RpcHandlerManager {
1359
1368
  socket.emit("rpc-register", { method: prefixedMethod });
1360
1369
  }
1361
1370
  }
1371
+ /**
1372
+ * Attach a candidate transport and wait until the server confirms every
1373
+ * method before a release handover closes the old transport.
1374
+ */
1375
+ async onSocketConnectAndWait(socket, timeoutMs = 1e4) {
1376
+ this.sockets.add(socket);
1377
+ const pending = new Set(this.handlers.keys());
1378
+ const acknowledged = /* @__PURE__ */ new Set();
1379
+ await new Promise((resolve, reject) => {
1380
+ let settleTimer = null;
1381
+ const timer = setTimeout(() => {
1382
+ cleanup();
1383
+ reject(new Error(`RPC registration timed out (${pending.size} pending)`));
1384
+ }, timeoutMs);
1385
+ timer.unref?.();
1386
+ const maybeSettle = () => {
1387
+ if (pending.size > 0 || settleTimer) return;
1388
+ settleTimer = setTimeout(() => {
1389
+ settleTimer = null;
1390
+ for (const prefixedMethod of this.handlers.keys()) {
1391
+ if (acknowledged.has(prefixedMethod) || pending.has(prefixedMethod)) continue;
1392
+ pending.add(prefixedMethod);
1393
+ socket.emit("rpc-register", { method: prefixedMethod });
1394
+ }
1395
+ if (pending.size === 0) {
1396
+ cleanup();
1397
+ resolve();
1398
+ }
1399
+ }, 0);
1400
+ settleTimer.unref?.();
1401
+ };
1402
+ const onRegistered = (data) => {
1403
+ if (typeof data?.method !== "string") return;
1404
+ acknowledged.add(data.method);
1405
+ pending.delete(data.method);
1406
+ maybeSettle();
1407
+ };
1408
+ const cleanup = () => {
1409
+ clearTimeout(timer);
1410
+ if (settleTimer) clearTimeout(settleTimer);
1411
+ socket.off("rpc-registered", onRegistered);
1412
+ };
1413
+ socket.on("rpc-registered", onRegistered);
1414
+ for (const prefixedMethod of pending) socket.emit("rpc-register", { method: prefixedMethod });
1415
+ maybeSettle();
1416
+ }).catch((error) => {
1417
+ this.sockets.delete(socket);
1418
+ throw error;
1419
+ });
1420
+ }
1362
1421
  onSocketDisconnect(socket) {
1363
1422
  if (socket) this.sockets.delete(socket);
1364
1423
  else this.sockets.clear();
@@ -2664,6 +2723,45 @@ class InvalidateSync {
2664
2723
  };
2665
2724
  }
2666
2725
 
2726
+ function record(value) {
2727
+ return value && typeof value === "object" && !Array.isArray(value) ? value : null;
2728
+ }
2729
+ function finiteNonNegative(value) {
2730
+ return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : void 0;
2731
+ }
2732
+ function firstNumber(source, keys) {
2733
+ for (const key of keys) {
2734
+ const value = finiteNonNegative(source[key]);
2735
+ if (value !== void 0) return value;
2736
+ }
2737
+ return void 0;
2738
+ }
2739
+ function normalizeAgentUsage(raw) {
2740
+ const outer = record(raw);
2741
+ if (!outer) return null;
2742
+ const wrapped = record(outer.totalTokenUsage) ?? record(outer.total_token_usage) ?? record(outer.total) ?? record(outer.usage) ?? outer;
2743
+ const input = firstNumber(wrapped, ["inputTokens", "input_tokens", "input"]);
2744
+ const output = firstNumber(wrapped, ["outputTokens", "output_tokens", "output"]);
2745
+ const cacheRead = firstNumber(wrapped, ["cachedInputTokens", "cached_input_tokens", "cachedReadTokens", "cacheReadInputTokens", "cache_read_input_tokens", "cache_read"]);
2746
+ const cacheCreation = firstNumber(wrapped, ["cacheCreationInputTokens", "cache_creation_input_tokens", "cachedWriteTokens", "cacheWriteInputTokens", "cache_write_input_tokens", "cache_creation"]);
2747
+ const reasoning = firstNumber(wrapped, ["reasoningOutputTokens", "reasoning_output_tokens", "thoughtTokens", "reasoning"]);
2748
+ const explicitTotal = firstNumber(wrapped, ["totalTokens", "total_tokens", "total"]);
2749
+ const fallbackTotal = input !== void 0 || output !== void 0 ? (input ?? 0) + (output ?? 0) : void 0;
2750
+ const total = explicitTotal ?? fallbackTotal;
2751
+ if (total === void 0) return null;
2752
+ return {
2753
+ total,
2754
+ ...input !== void 0 ? { input } : {},
2755
+ ...output !== void 0 ? { output } : {},
2756
+ ...cacheCreation !== void 0 ? { cache_creation: cacheCreation } : {},
2757
+ ...cacheRead !== void 0 ? { cache_read: cacheRead } : {},
2758
+ ...reasoning !== void 0 ? { reasoning } : {}
2759
+ };
2760
+ }
2761
+ function usageAgentKey(agent) {
2762
+ return agent.toLowerCase().replace(/[^a-z0-9_-]+/g, "-").replace(/^-+|-+$/g, "") || "unknown";
2763
+ }
2764
+
2667
2765
  class ApiSessionClient extends EventEmitter {
2668
2766
  token;
2669
2767
  sessionId;
@@ -2704,12 +2802,24 @@ class ApiSessionClient extends EventEmitter {
2704
2802
  startedSubagents: /* @__PURE__ */ new Set(),
2705
2803
  activeSubagents: /* @__PURE__ */ new Set()
2706
2804
  };
2805
+ seenClaudeUsageEvents = /* @__PURE__ */ new Set();
2806
+ claudeUsageTotals = {
2807
+ total: 0,
2808
+ input: 0,
2809
+ output: 0,
2810
+ cache_creation: 0,
2811
+ cache_read: 0,
2812
+ costTotal: 0,
2813
+ costInput: 0,
2814
+ costOutput: 0
2815
+ };
2707
2816
  lastSeq = 0;
2708
2817
  pendingOutbox = [];
2709
2818
  sendSync;
2710
2819
  receiveSync;
2711
2820
  directInboundLocalIds = /* @__PURE__ */ new Set();
2712
2821
  routedInboundLocalIds = /* @__PURE__ */ new Set();
2822
+ handoverInFlight = null;
2713
2823
  constructor(token, session) {
2714
2824
  super();
2715
2825
  this.token = token;
@@ -2729,26 +2839,15 @@ class ApiSessionClient extends EventEmitter {
2729
2839
  logger: (msg, data) => logger.debug(msg, data)
2730
2840
  });
2731
2841
  registerCommonHandlers(this.rpcHandlerManager, this.metadata.path);
2732
- this.socket = io(configuration.serverUrl, {
2733
- auth: {
2734
- token: this.token,
2735
- clientType: "session-scoped",
2736
- sessionId: this.sessionId,
2737
- happyClient: `cli-coding-session/${configuration.currentCliVersion}`
2738
- },
2739
- path: "/v1/updates",
2740
- reconnection: false,
2741
- transports: ["websocket"],
2742
- withCredentials: true,
2743
- autoConnect: false
2744
- });
2842
+ this.socket = this.createControlSocket();
2843
+ const controlSocket = this.socket;
2745
2844
  this.socket.on("connect", () => {
2746
2845
  logger.debug("Socket connected successfully");
2747
2846
  if (this.reconnectInterval) {
2748
2847
  clearInterval(this.reconnectInterval);
2749
2848
  this.reconnectInterval = null;
2750
2849
  }
2751
- this.rpcHandlerManager.onSocketConnect(this.socket);
2850
+ this.rpcHandlerManager.onSocketConnect(controlSocket);
2752
2851
  this.receiveSync.invalidate();
2753
2852
  void this.connectSessionRelay();
2754
2853
  });
@@ -2757,12 +2856,12 @@ class ApiSessionClient extends EventEmitter {
2757
2856
  });
2758
2857
  this.socket.on("disconnect", (reason) => {
2759
2858
  logger.debug(`[API] Socket disconnected: ${reason}`);
2760
- this.rpcHandlerManager.onSocketDisconnect();
2859
+ this.rpcHandlerManager.onSocketDisconnect(controlSocket);
2761
2860
  this.startSmartReconnect();
2762
2861
  });
2763
2862
  this.socket.on("connect_error", (error) => {
2764
2863
  logger.debug("[API] Socket connection error:", error);
2765
- this.rpcHandlerManager.onSocketDisconnect();
2864
+ this.rpcHandlerManager.onSocketDisconnect(controlSocket);
2766
2865
  if (error.message === "Session archived") {
2767
2866
  logger.debug("[API] Archived session rejected during reconnect; exiting");
2768
2867
  this.emit("archived");
@@ -2833,8 +2932,103 @@ class ApiSessionClient extends EventEmitter {
2833
2932
  this.socket.on("error", (error) => {
2834
2933
  logger.debug("[API] Socket error:", error);
2835
2934
  });
2935
+ this.socket.on("server-draining", (data) => {
2936
+ const parsed = ReleaseDrainNoticeSchema.safeParse(data);
2937
+ if (!parsed.success || parsed.data.deadline <= Date.now()) return;
2938
+ void this.startReleaseHandover(parsed.data);
2939
+ });
2836
2940
  this.socket.connect();
2837
2941
  }
2942
+ createControlSocket(handover) {
2943
+ return io(configuration.serverUrl, {
2944
+ auth: {
2945
+ token: this.token,
2946
+ clientType: "session-scoped",
2947
+ sessionId: this.sessionId,
2948
+ happyClient: `cli-coding-session/${configuration.currentCliVersion}`,
2949
+ ...handover ? { handoverEpoch: handover.epoch } : {}
2950
+ },
2951
+ ...handover ? { query: { vh_slot: handover.candidateSlot }, forceNew: true } : {},
2952
+ path: "/v1/updates",
2953
+ reconnection: false,
2954
+ transports: ["websocket"],
2955
+ withCredentials: true,
2956
+ autoConnect: false
2957
+ });
2958
+ }
2959
+ startReleaseHandover(notice) {
2960
+ if (this.handoverInFlight) return this.handoverInFlight;
2961
+ this.handoverInFlight = this.releaseHandover(notice).finally(() => {
2962
+ this.handoverInFlight = null;
2963
+ });
2964
+ return this.handoverInFlight;
2965
+ }
2966
+ async releaseHandover(notice) {
2967
+ const startedAt = Date.now();
2968
+ const previous = this.socket;
2969
+ const candidate = this.createControlSocket(notice);
2970
+ const transferredListeners = /* @__PURE__ */ new Map();
2971
+ for (const listener of previous.listeners("rpc-request")) candidate.on("rpc-request", listener);
2972
+ const timeoutMs = Math.max(1, Math.min(1e4, notice.deadline - Date.now()));
2973
+ try {
2974
+ await new Promise((resolve, reject) => {
2975
+ const timer = setTimeout(() => reject(new Error("release handover timeout")), timeoutMs);
2976
+ candidate.once("connect", () => {
2977
+ clearTimeout(timer);
2978
+ resolve();
2979
+ });
2980
+ candidate.once("connect_error", (error) => {
2981
+ clearTimeout(timer);
2982
+ reject(error);
2983
+ });
2984
+ candidate.connect();
2985
+ });
2986
+ await this.rpcHandlerManager.onSocketConnectAndWait(candidate, timeoutMs);
2987
+ if (this.socket !== previous) {
2988
+ this.rpcHandlerManager.onSocketDisconnect(candidate);
2989
+ candidate.close();
2990
+ return;
2991
+ }
2992
+ for (const event of ["session-archive", "update", "error", "server-draining"]) {
2993
+ const listeners = previous.listeners(event);
2994
+ previous.removeAllListeners(event);
2995
+ for (const listener of listeners) candidate.on(event, listener);
2996
+ transferredListeners.set(event, listeners);
2997
+ }
2998
+ this.socket = candidate;
2999
+ candidate.on("connect", () => {
3000
+ if (this.reconnectInterval) {
3001
+ clearInterval(this.reconnectInterval);
3002
+ this.reconnectInterval = null;
3003
+ }
3004
+ this.rpcHandlerManager.onSocketConnect(candidate);
3005
+ this.receiveSync.invalidate();
3006
+ });
3007
+ candidate.on("disconnect", () => {
3008
+ this.rpcHandlerManager.onSocketDisconnect(candidate);
3009
+ this.startSmartReconnect();
3010
+ });
3011
+ candidate.on("connect_error", () => {
3012
+ this.rpcHandlerManager.onSocketDisconnect(candidate);
3013
+ this.startSmartReconnect();
3014
+ });
3015
+ this.receiveSync.invalidate();
3016
+ candidate.emit("release-handover-result", { result: "success", durationMs: Date.now() - startedAt });
3017
+ this.rpcHandlerManager.onSocketDisconnect(previous);
3018
+ previous.removeAllListeners("disconnect");
3019
+ previous.close();
3020
+ } catch (error) {
3021
+ if (this.socket === candidate) this.socket = previous;
3022
+ for (const [event, listeners] of transferredListeners) {
3023
+ candidate.removeAllListeners(event);
3024
+ for (const listener of listeners) previous.on(event, listener);
3025
+ }
3026
+ this.rpcHandlerManager.onSocketDisconnect(candidate);
3027
+ candidate.close();
3028
+ if (previous.connected) previous.emit("release-handover-result", { result: "failed", durationMs: Date.now() - startedAt });
3029
+ logger.debug(`[API] Release handover failed: ${error instanceof Error ? error.message : error}`);
3030
+ }
3031
+ }
2838
3032
  onUserMessage(callback) {
2839
3033
  this.pendingMessageCallback = callback;
2840
3034
  while (this.pendingMessages.length > 0) {
@@ -3166,7 +3360,8 @@ class ApiSessionClient extends EventEmitter {
3166
3360
  }
3167
3361
  if (body.type === "assistant" && body.message?.usage) {
3168
3362
  try {
3169
- this.sendUsageData(body.message.usage, body.message.model);
3363
+ const eventId = typeof body.uuid === "string" ? body.uuid : typeof body.message.id === "string" ? body.message.id : void 0;
3364
+ this.sendUsageData(body.message.usage, body.message.model, eventId);
3170
3365
  } catch (error) {
3171
3366
  logger.debug("[SOCKET] Failed to send usage data:", error);
3172
3367
  }
@@ -3317,28 +3512,56 @@ class ApiSessionClient extends EventEmitter {
3317
3512
  /**
3318
3513
  * Send usage data to the server
3319
3514
  */
3320
- sendUsageData(usage, model) {
3515
+ sendUsageData(usage, model, eventId) {
3516
+ if (eventId && this.seenClaudeUsageEvents.has(eventId)) return;
3517
+ if (eventId) this.seenClaudeUsageEvents.add(eventId);
3321
3518
  const totalTokens = usage.input_tokens + usage.output_tokens + (usage.cache_creation_input_tokens || 0) + (usage.cache_read_input_tokens || 0);
3322
3519
  const costs = calculateCost(usage, model);
3520
+ this.claudeUsageTotals.total += totalTokens;
3521
+ this.claudeUsageTotals.input += usage.input_tokens;
3522
+ this.claudeUsageTotals.output += usage.output_tokens;
3523
+ this.claudeUsageTotals.cache_creation += usage.cache_creation_input_tokens || 0;
3524
+ this.claudeUsageTotals.cache_read += usage.cache_read_input_tokens || 0;
3525
+ this.claudeUsageTotals.costTotal += costs.total;
3526
+ this.claudeUsageTotals.costInput += costs.input;
3527
+ this.claudeUsageTotals.costOutput += costs.output;
3323
3528
  const usageReport = {
3529
+ // Keep the legacy key so upgrading replaces the old last-call row
3530
+ // instead of making old + new clients double-count one session.
3324
3531
  key: "claude-session",
3325
3532
  sessionId: this.sessionId,
3326
3533
  tokens: {
3327
- total: totalTokens,
3328
- input: usage.input_tokens,
3329
- output: usage.output_tokens,
3330
- cache_creation: usage.cache_creation_input_tokens || 0,
3331
- cache_read: usage.cache_read_input_tokens || 0
3534
+ total: this.claudeUsageTotals.total,
3535
+ input: this.claudeUsageTotals.input,
3536
+ output: this.claudeUsageTotals.output,
3537
+ cache_creation: this.claudeUsageTotals.cache_creation,
3538
+ cache_read: this.claudeUsageTotals.cache_read
3332
3539
  },
3333
3540
  cost: {
3334
- total: costs.total,
3335
- input: costs.input,
3336
- output: costs.output
3541
+ total: this.claudeUsageTotals.costTotal,
3542
+ input: this.claudeUsageTotals.costInput,
3543
+ output: this.claudeUsageTotals.costOutput
3337
3544
  }
3338
3545
  };
3339
3546
  logger.debugLargeJson("[SOCKET] Sending usage data:", usageReport);
3340
3547
  this.socket.emit("usage-report", usageReport);
3341
3548
  }
3549
+ /** Save a provider's cumulative session snapshot under one upsert key. */
3550
+ sendAgentUsageSnapshot(agent, rawUsage) {
3551
+ const tokens = normalizeAgentUsage(rawUsage);
3552
+ if (!tokens) return false;
3553
+ const usageReport = {
3554
+ key: `usage:${usageAgentKey(agent)}:session`,
3555
+ sessionId: this.sessionId,
3556
+ tokens,
3557
+ // Provider token-count events do not expose reliable billed cost.
3558
+ // The Web treats this sentinel as unknown, not as real $0.00.
3559
+ cost: { total: 0 }
3560
+ };
3561
+ logger.debugLargeJson("[SOCKET] Sending agent usage snapshot:", usageReport);
3562
+ this.socket.emit("usage-report", usageReport);
3563
+ return true;
3564
+ }
3342
3565
  /**
3343
3566
  * Returns the latest session metadata known to the client.
3344
3567
  */
@@ -13006,6 +13229,7 @@ class ApiMachineClient {
13006
13229
  stopSessionHandler = null;
13007
13230
  listTrackedSessionIds = null;
13008
13231
  reconnectInterval = null;
13232
+ handoverInFlight = null;
13009
13233
  // Terminals negotiated with `encStream` protect the live byte payload with
13010
13234
  // the per-machine key. This keeps it opaque to passive storage/forwarding
13011
13235
  // paths, but is not an operator trust boundary: the trusted relay can
@@ -13466,57 +13690,72 @@ class ApiMachineClient {
13466
13690
  connect() {
13467
13691
  const serverUrl = configuration.serverUrl.replace(/^http/, "ws");
13468
13692
  logger.debug(`[API MACHINE] Connecting to ${serverUrl}`);
13469
- this.socket = io(serverUrl, {
13693
+ this.socket = this.createControlSocket();
13694
+ const controlSocket = this.socket;
13695
+ this.bindRealtimeHandlers(controlSocket, "control");
13696
+ controlSocket.on("connect", () => {
13697
+ if (this.socket !== controlSocket) return;
13698
+ this.activateControlSocket(controlSocket);
13699
+ });
13700
+ controlSocket.on("disconnect", (reason) => {
13701
+ logger.debug(`[API MACHINE] Disconnected from server \u2014 reason: ${reason}`);
13702
+ this.rpcHandlerManager.onSocketDisconnect(controlSocket);
13703
+ if (!this.relaySocket?.connected) this.webTerminal.resetSubscribers();
13704
+ this.stopKeepAlive();
13705
+ if (this.socket === controlSocket) this.startSmartReconnect();
13706
+ });
13707
+ this.bindControlDataHandlers(controlSocket);
13708
+ }
13709
+ createControlSocket(handover) {
13710
+ const serverUrl = configuration.serverUrl.replace(/^http/, "ws");
13711
+ return io(serverUrl, {
13470
13712
  transports: ["websocket"],
13471
13713
  auth: {
13472
13714
  token: this.token,
13473
13715
  clientType: "machine-scoped",
13474
13716
  machineId: this.machine.id,
13475
- happyClient: `cli-daemon/${configuration.currentCliVersion}`
13717
+ happyClient: `cli-daemon/${configuration.currentCliVersion}`,
13718
+ ...handover ? { handoverEpoch: handover.epoch } : {}
13476
13719
  },
13720
+ ...handover ? { query: { vh_slot: handover.candidateSlot }, forceNew: true } : {},
13477
13721
  path: "/v1/updates",
13478
- reconnection: false
13479
- });
13480
- this.bindRealtimeHandlers(this.socket, "control");
13481
- this.socket.on("connect", () => {
13482
- logger.debug("[API MACHINE] Connected to server");
13483
- if (this.reconnectInterval) {
13484
- clearInterval(this.reconnectInterval);
13485
- this.reconnectInterval = null;
13486
- }
13487
- const initialTerminals = this.webTerminal.buildTerminalList();
13488
- this.webTerminal.primeListSignature(initialTerminals);
13489
- this.updateDaemonState((state) => {
13490
- const now = Date.now();
13491
- const currentState = withCurrentCliUpdateState(state, this.cliUpdateState);
13492
- return {
13493
- ...currentState,
13494
- status: "running",
13495
- pid: process.pid,
13496
- httpPort: this.machine.daemonState?.httpPort,
13497
- startedAt: now,
13498
- webTerminals: { updatedAt: now, terminals: initialTerminals },
13499
- // B-084: closed records survive daemon restarts (persisted
13500
- // in closed-terminals.json), so the connect snapshot ships
13501
- // them too — not just the incremental pushes.
13502
- closedTerminals: this.webTerminal.getClosedTerminals()
13503
- };
13504
- });
13505
- this.webTerminal.startListTracking((terminals) => this.pushTerminalList(terminals));
13506
- this.rpcHandlerManager.onSocketConnect(this.socket);
13507
- this.syncResumeSessionRpcRegistration();
13508
- this.startKeepAlive();
13509
- void this.reconcileArchivedSessions();
13510
- void this.refreshRelayConnection();
13722
+ reconnection: false,
13723
+ ...handover ? { autoConnect: false } : {}
13511
13724
  });
13512
- this.socket.on("disconnect", (reason) => {
13513
- logger.debug(`[API MACHINE] Disconnected from server \u2014 reason: ${reason}`);
13514
- this.rpcHandlerManager.onSocketDisconnect(this.socket);
13515
- if (!this.relaySocket?.connected) this.webTerminal.resetSubscribers();
13516
- this.stopKeepAlive();
13517
- this.startSmartReconnect();
13725
+ }
13726
+ activateControlSocket(socket, rpcAlreadyRegistered = false) {
13727
+ logger.debug("[API MACHINE] Connected to server");
13728
+ if (this.reconnectInterval) {
13729
+ clearInterval(this.reconnectInterval);
13730
+ this.reconnectInterval = null;
13731
+ }
13732
+ const initialTerminals = this.webTerminal.buildTerminalList();
13733
+ this.webTerminal.primeListSignature(initialTerminals);
13734
+ this.updateDaemonState((state) => {
13735
+ const now = Date.now();
13736
+ const currentState = withCurrentCliUpdateState(state, this.cliUpdateState);
13737
+ return {
13738
+ ...currentState,
13739
+ status: "running",
13740
+ pid: process.pid,
13741
+ httpPort: this.machine.daemonState?.httpPort,
13742
+ startedAt: now,
13743
+ webTerminals: { updatedAt: now, terminals: initialTerminals },
13744
+ // B-084: closed records survive daemon restarts (persisted
13745
+ // in closed-terminals.json), so the connect snapshot ships
13746
+ // them too — not just the incremental pushes.
13747
+ closedTerminals: this.webTerminal.getClosedTerminals()
13748
+ };
13518
13749
  });
13519
- this.socket.on("update", (data) => {
13750
+ this.webTerminal.startListTracking((terminals) => this.pushTerminalList(terminals));
13751
+ if (!rpcAlreadyRegistered) this.rpcHandlerManager.onSocketConnect(socket);
13752
+ this.syncResumeSessionRpcRegistration();
13753
+ this.startKeepAlive();
13754
+ void this.reconcileArchivedSessions();
13755
+ void this.refreshRelayConnection();
13756
+ }
13757
+ bindControlDataHandlers(socket) {
13758
+ socket.on("update", (data) => {
13520
13759
  if (data.body.t === "update-machine" && data.body.machineId === this.machine.id) {
13521
13760
  const update = data.body;
13522
13761
  if (update.metadata) {
@@ -13533,13 +13772,83 @@ class ApiMachineClient {
13533
13772
  logger.debug(`[API MACHINE] Received unknown update type: ${data.body.t}`);
13534
13773
  }
13535
13774
  });
13536
- this.socket.on("connect_error", (error) => {
13775
+ socket.on("connect_error", (error) => {
13537
13776
  logger.debug(`[API MACHINE] Connection error: ${error.message}`);
13538
- this.startSmartReconnect();
13777
+ if (this.socket === socket) this.startSmartReconnect();
13539
13778
  });
13540
- this.socket.io.on("error", (error) => {
13779
+ socket.io.on("error", (error) => {
13541
13780
  logger.debug("[API MACHINE] Socket error:", error);
13542
13781
  });
13782
+ socket.on("server-draining", (data) => {
13783
+ if (this.socket !== socket) return;
13784
+ const parsed = ReleaseDrainNoticeSchema.safeParse(data);
13785
+ if (!parsed.success || parsed.data.deadline <= Date.now()) return;
13786
+ void this.startReleaseHandover(parsed.data);
13787
+ });
13788
+ }
13789
+ startReleaseHandover(notice) {
13790
+ if (this.handoverInFlight) return this.handoverInFlight;
13791
+ this.handoverInFlight = this.releaseHandover(notice).finally(() => {
13792
+ this.handoverInFlight = null;
13793
+ });
13794
+ return this.handoverInFlight;
13795
+ }
13796
+ async releaseHandover(notice) {
13797
+ const startedAt = Date.now();
13798
+ const previous = this.socket;
13799
+ const candidate = this.createControlSocket(notice);
13800
+ let commandsTransferred = false;
13801
+ this.bindRpcRequestHandler(candidate, "control");
13802
+ const timeoutMs = Math.max(1, Math.min(1e4, notice.deadline - Date.now()));
13803
+ try {
13804
+ await new Promise((resolve, reject) => {
13805
+ const timer = setTimeout(() => reject(new Error("release handover timeout")), timeoutMs);
13806
+ candidate.once("connect", () => {
13807
+ clearTimeout(timer);
13808
+ resolve();
13809
+ });
13810
+ candidate.once("connect_error", (error) => {
13811
+ clearTimeout(timer);
13812
+ reject(error);
13813
+ });
13814
+ candidate.connect();
13815
+ });
13816
+ await this.rpcHandlerManager.onSocketConnectAndWait(candidate, timeoutMs);
13817
+ if (this.socket !== previous) {
13818
+ this.rpcHandlerManager.onSocketDisconnect(candidate);
13819
+ candidate.close();
13820
+ return;
13821
+ }
13822
+ this.unbindMachineCommandHandlers(previous);
13823
+ this.bindMachineCommandHandlers(candidate);
13824
+ commandsTransferred = true;
13825
+ this.bindControlDataHandlers(candidate);
13826
+ this.socket = candidate;
13827
+ candidate.on("connect", () => {
13828
+ if (this.socket === candidate) this.activateControlSocket(candidate);
13829
+ });
13830
+ candidate.on("disconnect", () => {
13831
+ this.rpcHandlerManager.onSocketDisconnect(candidate);
13832
+ if (!this.relaySocket?.connected) this.webTerminal.resetSubscribers();
13833
+ this.stopKeepAlive();
13834
+ if (this.socket === candidate) this.startSmartReconnect();
13835
+ });
13836
+ this.activateControlSocket(candidate, true);
13837
+ candidate.emit("release-handover-result", { result: "success", durationMs: Date.now() - startedAt });
13838
+ this.rpcHandlerManager.onSocketDisconnect(previous);
13839
+ previous.removeAllListeners("disconnect");
13840
+ previous.close();
13841
+ } catch (error) {
13842
+ if (this.socket === candidate) this.socket = previous;
13843
+ if (commandsTransferred) {
13844
+ this.unbindMachineCommandHandlers(candidate);
13845
+ this.bindMachineCommandHandlers(previous);
13846
+ }
13847
+ this.rpcHandlerManager.onSocketDisconnect(candidate);
13848
+ candidate.close();
13849
+ if (previous.connected) previous.emit("release-handover-result", { result: "failed", durationMs: Date.now() - startedAt });
13850
+ logger.debug(`[API MACHINE] Release handover failed: ${error instanceof Error ? error.message : error}`);
13851
+ }
13543
13852
  }
13544
13853
  startKeepAlive() {
13545
13854
  this.stopKeepAlive();
@@ -13573,11 +13882,13 @@ class ApiMachineClient {
13573
13882
  }, 2e4);
13574
13883
  logger.debug("[API MACHINE] Keep-alive started (20s interval)");
13575
13884
  }
13576
- bindRealtimeHandlers(socket, transport) {
13885
+ bindRpcRequestHandler(socket, transport) {
13577
13886
  socket.on("rpc-request", async (data, callback) => {
13578
13887
  logger.debugLargeJson(`[API MACHINE] Received RPC request via ${transport}:`, data);
13579
13888
  callback(await this.rpcHandlerManager.handleRequest(data));
13580
13889
  });
13890
+ }
13891
+ bindMachineCommandHandlers(socket) {
13581
13892
  socket.on("terminal-input", (data) => {
13582
13893
  let payload = data.data;
13583
13894
  if (data.enc) {
@@ -13595,6 +13906,16 @@ class ApiMachineClient {
13595
13906
  logger.debug(`[API MACHINE] Server archived session ${data.sessionId}; local process stopped=${stopped}`);
13596
13907
  });
13597
13908
  }
13909
+ unbindMachineCommandHandlers(socket) {
13910
+ socket.removeAllListeners("terminal-input");
13911
+ socket.removeAllListeners("terminal-resize");
13912
+ socket.removeAllListeners("terminal-close");
13913
+ socket.removeAllListeners("session-archive");
13914
+ }
13915
+ bindRealtimeHandlers(socket, transport) {
13916
+ this.bindRpcRequestHandler(socket, transport);
13917
+ this.bindMachineCommandHandlers(socket);
13918
+ }
13598
13919
  /** Reconcile commands missed while the daemon was offline. The server
13599
13920
  * returns only ids owned by this account and durably archived in the DB. */
13600
13921
  async reconcileArchivedSessions() {