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
@@ -51,7 +51,7 @@ var z__namespace = /*#__PURE__*/_interopNamespaceDefault(z);
51
51
  var pty__namespace = /*#__PURE__*/_interopNamespaceDefault(pty);
52
52
 
53
53
  var name = "very-happy-cli";
54
- var version = "0.2.75";
54
+ var version = "0.2.77";
55
55
  var description = "Remote multi-agent workspace CLI with deep Claude integration, native terminal streaming, and self-hosting.";
56
56
  var author = "Kirill Dubovitskiy";
57
57
  var contributors = [
@@ -164,12 +164,12 @@ var dependencies = {
164
164
  zod: "^4.0.0"
165
165
  };
166
166
  var optionalDependencies = {
167
- "very-happy-tools-arm64-darwin": "0.2.75",
168
- "very-happy-tools-x64-darwin": "0.2.75",
169
- "very-happy-tools-arm64-linux": "0.2.75",
170
- "very-happy-tools-x64-linux": "0.2.75",
171
- "very-happy-tools-arm64-win32": "0.2.75",
172
- "very-happy-tools-x64-win32": "0.2.75"
167
+ "very-happy-tools-arm64-darwin": "0.2.77",
168
+ "very-happy-tools-x64-darwin": "0.2.77",
169
+ "very-happy-tools-arm64-linux": "0.2.77",
170
+ "very-happy-tools-x64-linux": "0.2.77",
171
+ "very-happy-tools-arm64-win32": "0.2.77",
172
+ "very-happy-tools-x64-win32": "0.2.77"
173
173
  };
174
174
  var devDependencies = {
175
175
  "@slopus/happy-wire": "workspace:*",
@@ -918,6 +918,15 @@ z.z.object({
918
918
  region: z.z.string().min(1).max(64),
919
919
  version: z.z.string().min(1).max(64)
920
920
  });
921
+ const ReleaseSlotSchema = z.z.enum(["blue", "green"]);
922
+ const ReleaseDrainNoticeSchema = z.z.object({
923
+ epoch: z.z.string().regex(/^[A-Za-z0-9._-]{8,128}$/),
924
+ fromRelease: z.z.string().regex(/^[0-9a-f]{40}$/),
925
+ toRelease: z.z.string().regex(/^[0-9a-f]{40}$/),
926
+ candidateSlot: ReleaseSlotSchema,
927
+ deadline: z.z.number().int().positive(),
928
+ mode: z.z.literal("make-before-break")
929
+ });
921
930
 
922
931
  z.z.object({
923
932
  host: z.z.string(),
@@ -1381,6 +1390,56 @@ class RpcHandlerManager {
1381
1390
  socket.emit("rpc-register", { method: prefixedMethod });
1382
1391
  }
1383
1392
  }
1393
+ /**
1394
+ * Attach a candidate transport and wait until the server confirms every
1395
+ * method before a release handover closes the old transport.
1396
+ */
1397
+ async onSocketConnectAndWait(socket, timeoutMs = 1e4) {
1398
+ this.sockets.add(socket);
1399
+ const pending = new Set(this.handlers.keys());
1400
+ const acknowledged = /* @__PURE__ */ new Set();
1401
+ await new Promise((resolve, reject) => {
1402
+ let settleTimer = null;
1403
+ const timer = setTimeout(() => {
1404
+ cleanup();
1405
+ reject(new Error(`RPC registration timed out (${pending.size} pending)`));
1406
+ }, timeoutMs);
1407
+ timer.unref?.();
1408
+ const maybeSettle = () => {
1409
+ if (pending.size > 0 || settleTimer) return;
1410
+ settleTimer = setTimeout(() => {
1411
+ settleTimer = null;
1412
+ for (const prefixedMethod of this.handlers.keys()) {
1413
+ if (acknowledged.has(prefixedMethod) || pending.has(prefixedMethod)) continue;
1414
+ pending.add(prefixedMethod);
1415
+ socket.emit("rpc-register", { method: prefixedMethod });
1416
+ }
1417
+ if (pending.size === 0) {
1418
+ cleanup();
1419
+ resolve();
1420
+ }
1421
+ }, 0);
1422
+ settleTimer.unref?.();
1423
+ };
1424
+ const onRegistered = (data) => {
1425
+ if (typeof data?.method !== "string") return;
1426
+ acknowledged.add(data.method);
1427
+ pending.delete(data.method);
1428
+ maybeSettle();
1429
+ };
1430
+ const cleanup = () => {
1431
+ clearTimeout(timer);
1432
+ if (settleTimer) clearTimeout(settleTimer);
1433
+ socket.off("rpc-registered", onRegistered);
1434
+ };
1435
+ socket.on("rpc-registered", onRegistered);
1436
+ for (const prefixedMethod of pending) socket.emit("rpc-register", { method: prefixedMethod });
1437
+ maybeSettle();
1438
+ }).catch((error) => {
1439
+ this.sockets.delete(socket);
1440
+ throw error;
1441
+ });
1442
+ }
1384
1443
  onSocketDisconnect(socket) {
1385
1444
  if (socket) this.sockets.delete(socket);
1386
1445
  else this.sockets.clear();
@@ -1415,7 +1474,7 @@ class RpcHandlerManager {
1415
1474
  }
1416
1475
  }
1417
1476
 
1418
- const __dirname$1 = path$1.dirname(url.fileURLToPath((typeof document === 'undefined' ? require('u' + 'rl').pathToFileURL(__filename).href : (_documentCurrentScript && _documentCurrentScript.tagName.toUpperCase() === 'SCRIPT' && _documentCurrentScript.src || new URL('types-DEre2zxO.cjs', document.baseURI).href))));
1477
+ const __dirname$1 = path$1.dirname(url.fileURLToPath((typeof document === 'undefined' ? require('u' + 'rl').pathToFileURL(__filename).href : (_documentCurrentScript && _documentCurrentScript.tagName.toUpperCase() === 'SCRIPT' && _documentCurrentScript.src || new URL('types-NwtcBgfS.cjs', document.baseURI).href))));
1419
1478
  function projectPath() {
1420
1479
  const path = path$1.resolve(__dirname$1, "..");
1421
1480
  return path;
@@ -2686,6 +2745,45 @@ class InvalidateSync {
2686
2745
  };
2687
2746
  }
2688
2747
 
2748
+ function record(value) {
2749
+ return value && typeof value === "object" && !Array.isArray(value) ? value : null;
2750
+ }
2751
+ function finiteNonNegative(value) {
2752
+ return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : void 0;
2753
+ }
2754
+ function firstNumber(source, keys) {
2755
+ for (const key of keys) {
2756
+ const value = finiteNonNegative(source[key]);
2757
+ if (value !== void 0) return value;
2758
+ }
2759
+ return void 0;
2760
+ }
2761
+ function normalizeAgentUsage(raw) {
2762
+ const outer = record(raw);
2763
+ if (!outer) return null;
2764
+ const wrapped = record(outer.totalTokenUsage) ?? record(outer.total_token_usage) ?? record(outer.total) ?? record(outer.usage) ?? outer;
2765
+ const input = firstNumber(wrapped, ["inputTokens", "input_tokens", "input"]);
2766
+ const output = firstNumber(wrapped, ["outputTokens", "output_tokens", "output"]);
2767
+ const cacheRead = firstNumber(wrapped, ["cachedInputTokens", "cached_input_tokens", "cachedReadTokens", "cacheReadInputTokens", "cache_read_input_tokens", "cache_read"]);
2768
+ const cacheCreation = firstNumber(wrapped, ["cacheCreationInputTokens", "cache_creation_input_tokens", "cachedWriteTokens", "cacheWriteInputTokens", "cache_write_input_tokens", "cache_creation"]);
2769
+ const reasoning = firstNumber(wrapped, ["reasoningOutputTokens", "reasoning_output_tokens", "thoughtTokens", "reasoning"]);
2770
+ const explicitTotal = firstNumber(wrapped, ["totalTokens", "total_tokens", "total"]);
2771
+ const fallbackTotal = input !== void 0 || output !== void 0 ? (input ?? 0) + (output ?? 0) : void 0;
2772
+ const total = explicitTotal ?? fallbackTotal;
2773
+ if (total === void 0) return null;
2774
+ return {
2775
+ total,
2776
+ ...input !== void 0 ? { input } : {},
2777
+ ...output !== void 0 ? { output } : {},
2778
+ ...cacheCreation !== void 0 ? { cache_creation: cacheCreation } : {},
2779
+ ...cacheRead !== void 0 ? { cache_read: cacheRead } : {},
2780
+ ...reasoning !== void 0 ? { reasoning } : {}
2781
+ };
2782
+ }
2783
+ function usageAgentKey(agent) {
2784
+ return agent.toLowerCase().replace(/[^a-z0-9_-]+/g, "-").replace(/^-+|-+$/g, "") || "unknown";
2785
+ }
2786
+
2689
2787
  class ApiSessionClient extends node_events.EventEmitter {
2690
2788
  token;
2691
2789
  sessionId;
@@ -2726,12 +2824,24 @@ class ApiSessionClient extends node_events.EventEmitter {
2726
2824
  startedSubagents: /* @__PURE__ */ new Set(),
2727
2825
  activeSubagents: /* @__PURE__ */ new Set()
2728
2826
  };
2827
+ seenClaudeUsageEvents = /* @__PURE__ */ new Set();
2828
+ claudeUsageTotals = {
2829
+ total: 0,
2830
+ input: 0,
2831
+ output: 0,
2832
+ cache_creation: 0,
2833
+ cache_read: 0,
2834
+ costTotal: 0,
2835
+ costInput: 0,
2836
+ costOutput: 0
2837
+ };
2729
2838
  lastSeq = 0;
2730
2839
  pendingOutbox = [];
2731
2840
  sendSync;
2732
2841
  receiveSync;
2733
2842
  directInboundLocalIds = /* @__PURE__ */ new Set();
2734
2843
  routedInboundLocalIds = /* @__PURE__ */ new Set();
2844
+ handoverInFlight = null;
2735
2845
  constructor(token, session) {
2736
2846
  super();
2737
2847
  this.token = token;
@@ -2751,26 +2861,15 @@ class ApiSessionClient extends node_events.EventEmitter {
2751
2861
  logger: (msg, data) => logger.debug(msg, data)
2752
2862
  });
2753
2863
  registerCommonHandlers(this.rpcHandlerManager, this.metadata.path);
2754
- this.socket = socket_ioClient.io(configuration.serverUrl, {
2755
- auth: {
2756
- token: this.token,
2757
- clientType: "session-scoped",
2758
- sessionId: this.sessionId,
2759
- happyClient: `cli-coding-session/${configuration.currentCliVersion}`
2760
- },
2761
- path: "/v1/updates",
2762
- reconnection: false,
2763
- transports: ["websocket"],
2764
- withCredentials: true,
2765
- autoConnect: false
2766
- });
2864
+ this.socket = this.createControlSocket();
2865
+ const controlSocket = this.socket;
2767
2866
  this.socket.on("connect", () => {
2768
2867
  logger.debug("Socket connected successfully");
2769
2868
  if (this.reconnectInterval) {
2770
2869
  clearInterval(this.reconnectInterval);
2771
2870
  this.reconnectInterval = null;
2772
2871
  }
2773
- this.rpcHandlerManager.onSocketConnect(this.socket);
2872
+ this.rpcHandlerManager.onSocketConnect(controlSocket);
2774
2873
  this.receiveSync.invalidate();
2775
2874
  void this.connectSessionRelay();
2776
2875
  });
@@ -2779,12 +2878,12 @@ class ApiSessionClient extends node_events.EventEmitter {
2779
2878
  });
2780
2879
  this.socket.on("disconnect", (reason) => {
2781
2880
  logger.debug(`[API] Socket disconnected: ${reason}`);
2782
- this.rpcHandlerManager.onSocketDisconnect();
2881
+ this.rpcHandlerManager.onSocketDisconnect(controlSocket);
2783
2882
  this.startSmartReconnect();
2784
2883
  });
2785
2884
  this.socket.on("connect_error", (error) => {
2786
2885
  logger.debug("[API] Socket connection error:", error);
2787
- this.rpcHandlerManager.onSocketDisconnect();
2886
+ this.rpcHandlerManager.onSocketDisconnect(controlSocket);
2788
2887
  if (error.message === "Session archived") {
2789
2888
  logger.debug("[API] Archived session rejected during reconnect; exiting");
2790
2889
  this.emit("archived");
@@ -2855,8 +2954,103 @@ class ApiSessionClient extends node_events.EventEmitter {
2855
2954
  this.socket.on("error", (error) => {
2856
2955
  logger.debug("[API] Socket error:", error);
2857
2956
  });
2957
+ this.socket.on("server-draining", (data) => {
2958
+ const parsed = ReleaseDrainNoticeSchema.safeParse(data);
2959
+ if (!parsed.success || parsed.data.deadline <= Date.now()) return;
2960
+ void this.startReleaseHandover(parsed.data);
2961
+ });
2858
2962
  this.socket.connect();
2859
2963
  }
2964
+ createControlSocket(handover) {
2965
+ return socket_ioClient.io(configuration.serverUrl, {
2966
+ auth: {
2967
+ token: this.token,
2968
+ clientType: "session-scoped",
2969
+ sessionId: this.sessionId,
2970
+ happyClient: `cli-coding-session/${configuration.currentCliVersion}`,
2971
+ ...handover ? { handoverEpoch: handover.epoch } : {}
2972
+ },
2973
+ ...handover ? { query: { vh_slot: handover.candidateSlot }, forceNew: true } : {},
2974
+ path: "/v1/updates",
2975
+ reconnection: false,
2976
+ transports: ["websocket"],
2977
+ withCredentials: true,
2978
+ autoConnect: false
2979
+ });
2980
+ }
2981
+ startReleaseHandover(notice) {
2982
+ if (this.handoverInFlight) return this.handoverInFlight;
2983
+ this.handoverInFlight = this.releaseHandover(notice).finally(() => {
2984
+ this.handoverInFlight = null;
2985
+ });
2986
+ return this.handoverInFlight;
2987
+ }
2988
+ async releaseHandover(notice) {
2989
+ const startedAt = Date.now();
2990
+ const previous = this.socket;
2991
+ const candidate = this.createControlSocket(notice);
2992
+ const transferredListeners = /* @__PURE__ */ new Map();
2993
+ for (const listener of previous.listeners("rpc-request")) candidate.on("rpc-request", listener);
2994
+ const timeoutMs = Math.max(1, Math.min(1e4, notice.deadline - Date.now()));
2995
+ try {
2996
+ await new Promise((resolve, reject) => {
2997
+ const timer = setTimeout(() => reject(new Error("release handover timeout")), timeoutMs);
2998
+ candidate.once("connect", () => {
2999
+ clearTimeout(timer);
3000
+ resolve();
3001
+ });
3002
+ candidate.once("connect_error", (error) => {
3003
+ clearTimeout(timer);
3004
+ reject(error);
3005
+ });
3006
+ candidate.connect();
3007
+ });
3008
+ await this.rpcHandlerManager.onSocketConnectAndWait(candidate, timeoutMs);
3009
+ if (this.socket !== previous) {
3010
+ this.rpcHandlerManager.onSocketDisconnect(candidate);
3011
+ candidate.close();
3012
+ return;
3013
+ }
3014
+ for (const event of ["session-archive", "update", "error", "server-draining"]) {
3015
+ const listeners = previous.listeners(event);
3016
+ previous.removeAllListeners(event);
3017
+ for (const listener of listeners) candidate.on(event, listener);
3018
+ transferredListeners.set(event, listeners);
3019
+ }
3020
+ this.socket = candidate;
3021
+ candidate.on("connect", () => {
3022
+ if (this.reconnectInterval) {
3023
+ clearInterval(this.reconnectInterval);
3024
+ this.reconnectInterval = null;
3025
+ }
3026
+ this.rpcHandlerManager.onSocketConnect(candidate);
3027
+ this.receiveSync.invalidate();
3028
+ });
3029
+ candidate.on("disconnect", () => {
3030
+ this.rpcHandlerManager.onSocketDisconnect(candidate);
3031
+ this.startSmartReconnect();
3032
+ });
3033
+ candidate.on("connect_error", () => {
3034
+ this.rpcHandlerManager.onSocketDisconnect(candidate);
3035
+ this.startSmartReconnect();
3036
+ });
3037
+ this.receiveSync.invalidate();
3038
+ candidate.emit("release-handover-result", { result: "success", durationMs: Date.now() - startedAt });
3039
+ this.rpcHandlerManager.onSocketDisconnect(previous);
3040
+ previous.removeAllListeners("disconnect");
3041
+ previous.close();
3042
+ } catch (error) {
3043
+ if (this.socket === candidate) this.socket = previous;
3044
+ for (const [event, listeners] of transferredListeners) {
3045
+ candidate.removeAllListeners(event);
3046
+ for (const listener of listeners) previous.on(event, listener);
3047
+ }
3048
+ this.rpcHandlerManager.onSocketDisconnect(candidate);
3049
+ candidate.close();
3050
+ if (previous.connected) previous.emit("release-handover-result", { result: "failed", durationMs: Date.now() - startedAt });
3051
+ logger.debug(`[API] Release handover failed: ${error instanceof Error ? error.message : error}`);
3052
+ }
3053
+ }
2860
3054
  onUserMessage(callback) {
2861
3055
  this.pendingMessageCallback = callback;
2862
3056
  while (this.pendingMessages.length > 0) {
@@ -3188,7 +3382,8 @@ class ApiSessionClient extends node_events.EventEmitter {
3188
3382
  }
3189
3383
  if (body.type === "assistant" && body.message?.usage) {
3190
3384
  try {
3191
- this.sendUsageData(body.message.usage, body.message.model);
3385
+ const eventId = typeof body.uuid === "string" ? body.uuid : typeof body.message.id === "string" ? body.message.id : void 0;
3386
+ this.sendUsageData(body.message.usage, body.message.model, eventId);
3192
3387
  } catch (error) {
3193
3388
  logger.debug("[SOCKET] Failed to send usage data:", error);
3194
3389
  }
@@ -3339,28 +3534,56 @@ class ApiSessionClient extends node_events.EventEmitter {
3339
3534
  /**
3340
3535
  * Send usage data to the server
3341
3536
  */
3342
- sendUsageData(usage, model) {
3537
+ sendUsageData(usage, model, eventId) {
3538
+ if (eventId && this.seenClaudeUsageEvents.has(eventId)) return;
3539
+ if (eventId) this.seenClaudeUsageEvents.add(eventId);
3343
3540
  const totalTokens = usage.input_tokens + usage.output_tokens + (usage.cache_creation_input_tokens || 0) + (usage.cache_read_input_tokens || 0);
3344
3541
  const costs = calculateCost(usage, model);
3542
+ this.claudeUsageTotals.total += totalTokens;
3543
+ this.claudeUsageTotals.input += usage.input_tokens;
3544
+ this.claudeUsageTotals.output += usage.output_tokens;
3545
+ this.claudeUsageTotals.cache_creation += usage.cache_creation_input_tokens || 0;
3546
+ this.claudeUsageTotals.cache_read += usage.cache_read_input_tokens || 0;
3547
+ this.claudeUsageTotals.costTotal += costs.total;
3548
+ this.claudeUsageTotals.costInput += costs.input;
3549
+ this.claudeUsageTotals.costOutput += costs.output;
3345
3550
  const usageReport = {
3551
+ // Keep the legacy key so upgrading replaces the old last-call row
3552
+ // instead of making old + new clients double-count one session.
3346
3553
  key: "claude-session",
3347
3554
  sessionId: this.sessionId,
3348
3555
  tokens: {
3349
- total: totalTokens,
3350
- input: usage.input_tokens,
3351
- output: usage.output_tokens,
3352
- cache_creation: usage.cache_creation_input_tokens || 0,
3353
- cache_read: usage.cache_read_input_tokens || 0
3556
+ total: this.claudeUsageTotals.total,
3557
+ input: this.claudeUsageTotals.input,
3558
+ output: this.claudeUsageTotals.output,
3559
+ cache_creation: this.claudeUsageTotals.cache_creation,
3560
+ cache_read: this.claudeUsageTotals.cache_read
3354
3561
  },
3355
3562
  cost: {
3356
- total: costs.total,
3357
- input: costs.input,
3358
- output: costs.output
3563
+ total: this.claudeUsageTotals.costTotal,
3564
+ input: this.claudeUsageTotals.costInput,
3565
+ output: this.claudeUsageTotals.costOutput
3359
3566
  }
3360
3567
  };
3361
3568
  logger.debugLargeJson("[SOCKET] Sending usage data:", usageReport);
3362
3569
  this.socket.emit("usage-report", usageReport);
3363
3570
  }
3571
+ /** Save a provider's cumulative session snapshot under one upsert key. */
3572
+ sendAgentUsageSnapshot(agent, rawUsage) {
3573
+ const tokens = normalizeAgentUsage(rawUsage);
3574
+ if (!tokens) return false;
3575
+ const usageReport = {
3576
+ key: `usage:${usageAgentKey(agent)}:session`,
3577
+ sessionId: this.sessionId,
3578
+ tokens,
3579
+ // Provider token-count events do not expose reliable billed cost.
3580
+ // The Web treats this sentinel as unknown, not as real $0.00.
3581
+ cost: { total: 0 }
3582
+ };
3583
+ logger.debugLargeJson("[SOCKET] Sending agent usage snapshot:", usageReport);
3584
+ this.socket.emit("usage-report", usageReport);
3585
+ return true;
3586
+ }
3364
3587
  /**
3365
3588
  * Returns the latest session metadata known to the client.
3366
3589
  */
@@ -13028,6 +13251,7 @@ class ApiMachineClient {
13028
13251
  stopSessionHandler = null;
13029
13252
  listTrackedSessionIds = null;
13030
13253
  reconnectInterval = null;
13254
+ handoverInFlight = null;
13031
13255
  // Terminals negotiated with `encStream` protect the live byte payload with
13032
13256
  // the per-machine key. This keeps it opaque to passive storage/forwarding
13033
13257
  // paths, but is not an operator trust boundary: the trusted relay can
@@ -13488,57 +13712,72 @@ class ApiMachineClient {
13488
13712
  connect() {
13489
13713
  const serverUrl = configuration.serverUrl.replace(/^http/, "ws");
13490
13714
  logger.debug(`[API MACHINE] Connecting to ${serverUrl}`);
13491
- this.socket = socket_ioClient.io(serverUrl, {
13715
+ this.socket = this.createControlSocket();
13716
+ const controlSocket = this.socket;
13717
+ this.bindRealtimeHandlers(controlSocket, "control");
13718
+ controlSocket.on("connect", () => {
13719
+ if (this.socket !== controlSocket) return;
13720
+ this.activateControlSocket(controlSocket);
13721
+ });
13722
+ controlSocket.on("disconnect", (reason) => {
13723
+ logger.debug(`[API MACHINE] Disconnected from server \u2014 reason: ${reason}`);
13724
+ this.rpcHandlerManager.onSocketDisconnect(controlSocket);
13725
+ if (!this.relaySocket?.connected) this.webTerminal.resetSubscribers();
13726
+ this.stopKeepAlive();
13727
+ if (this.socket === controlSocket) this.startSmartReconnect();
13728
+ });
13729
+ this.bindControlDataHandlers(controlSocket);
13730
+ }
13731
+ createControlSocket(handover) {
13732
+ const serverUrl = configuration.serverUrl.replace(/^http/, "ws");
13733
+ return socket_ioClient.io(serverUrl, {
13492
13734
  transports: ["websocket"],
13493
13735
  auth: {
13494
13736
  token: this.token,
13495
13737
  clientType: "machine-scoped",
13496
13738
  machineId: this.machine.id,
13497
- happyClient: `cli-daemon/${configuration.currentCliVersion}`
13739
+ happyClient: `cli-daemon/${configuration.currentCliVersion}`,
13740
+ ...handover ? { handoverEpoch: handover.epoch } : {}
13498
13741
  },
13742
+ ...handover ? { query: { vh_slot: handover.candidateSlot }, forceNew: true } : {},
13499
13743
  path: "/v1/updates",
13500
- reconnection: false
13501
- });
13502
- this.bindRealtimeHandlers(this.socket, "control");
13503
- this.socket.on("connect", () => {
13504
- logger.debug("[API MACHINE] Connected to server");
13505
- if (this.reconnectInterval) {
13506
- clearInterval(this.reconnectInterval);
13507
- this.reconnectInterval = null;
13508
- }
13509
- const initialTerminals = this.webTerminal.buildTerminalList();
13510
- this.webTerminal.primeListSignature(initialTerminals);
13511
- this.updateDaemonState((state) => {
13512
- const now = Date.now();
13513
- const currentState = withCurrentCliUpdateState(state, this.cliUpdateState);
13514
- return {
13515
- ...currentState,
13516
- status: "running",
13517
- pid: process.pid,
13518
- httpPort: this.machine.daemonState?.httpPort,
13519
- startedAt: now,
13520
- webTerminals: { updatedAt: now, terminals: initialTerminals },
13521
- // B-084: closed records survive daemon restarts (persisted
13522
- // in closed-terminals.json), so the connect snapshot ships
13523
- // them too — not just the incremental pushes.
13524
- closedTerminals: this.webTerminal.getClosedTerminals()
13525
- };
13526
- });
13527
- this.webTerminal.startListTracking((terminals) => this.pushTerminalList(terminals));
13528
- this.rpcHandlerManager.onSocketConnect(this.socket);
13529
- this.syncResumeSessionRpcRegistration();
13530
- this.startKeepAlive();
13531
- void this.reconcileArchivedSessions();
13532
- void this.refreshRelayConnection();
13744
+ reconnection: false,
13745
+ ...handover ? { autoConnect: false } : {}
13533
13746
  });
13534
- this.socket.on("disconnect", (reason) => {
13535
- logger.debug(`[API MACHINE] Disconnected from server \u2014 reason: ${reason}`);
13536
- this.rpcHandlerManager.onSocketDisconnect(this.socket);
13537
- if (!this.relaySocket?.connected) this.webTerminal.resetSubscribers();
13538
- this.stopKeepAlive();
13539
- this.startSmartReconnect();
13747
+ }
13748
+ activateControlSocket(socket, rpcAlreadyRegistered = false) {
13749
+ logger.debug("[API MACHINE] Connected to server");
13750
+ if (this.reconnectInterval) {
13751
+ clearInterval(this.reconnectInterval);
13752
+ this.reconnectInterval = null;
13753
+ }
13754
+ const initialTerminals = this.webTerminal.buildTerminalList();
13755
+ this.webTerminal.primeListSignature(initialTerminals);
13756
+ this.updateDaemonState((state) => {
13757
+ const now = Date.now();
13758
+ const currentState = withCurrentCliUpdateState(state, this.cliUpdateState);
13759
+ return {
13760
+ ...currentState,
13761
+ status: "running",
13762
+ pid: process.pid,
13763
+ httpPort: this.machine.daemonState?.httpPort,
13764
+ startedAt: now,
13765
+ webTerminals: { updatedAt: now, terminals: initialTerminals },
13766
+ // B-084: closed records survive daemon restarts (persisted
13767
+ // in closed-terminals.json), so the connect snapshot ships
13768
+ // them too — not just the incremental pushes.
13769
+ closedTerminals: this.webTerminal.getClosedTerminals()
13770
+ };
13540
13771
  });
13541
- this.socket.on("update", (data) => {
13772
+ this.webTerminal.startListTracking((terminals) => this.pushTerminalList(terminals));
13773
+ if (!rpcAlreadyRegistered) this.rpcHandlerManager.onSocketConnect(socket);
13774
+ this.syncResumeSessionRpcRegistration();
13775
+ this.startKeepAlive();
13776
+ void this.reconcileArchivedSessions();
13777
+ void this.refreshRelayConnection();
13778
+ }
13779
+ bindControlDataHandlers(socket) {
13780
+ socket.on("update", (data) => {
13542
13781
  if (data.body.t === "update-machine" && data.body.machineId === this.machine.id) {
13543
13782
  const update = data.body;
13544
13783
  if (update.metadata) {
@@ -13555,13 +13794,83 @@ class ApiMachineClient {
13555
13794
  logger.debug(`[API MACHINE] Received unknown update type: ${data.body.t}`);
13556
13795
  }
13557
13796
  });
13558
- this.socket.on("connect_error", (error) => {
13797
+ socket.on("connect_error", (error) => {
13559
13798
  logger.debug(`[API MACHINE] Connection error: ${error.message}`);
13560
- this.startSmartReconnect();
13799
+ if (this.socket === socket) this.startSmartReconnect();
13561
13800
  });
13562
- this.socket.io.on("error", (error) => {
13801
+ socket.io.on("error", (error) => {
13563
13802
  logger.debug("[API MACHINE] Socket error:", error);
13564
13803
  });
13804
+ socket.on("server-draining", (data) => {
13805
+ if (this.socket !== socket) return;
13806
+ const parsed = ReleaseDrainNoticeSchema.safeParse(data);
13807
+ if (!parsed.success || parsed.data.deadline <= Date.now()) return;
13808
+ void this.startReleaseHandover(parsed.data);
13809
+ });
13810
+ }
13811
+ startReleaseHandover(notice) {
13812
+ if (this.handoverInFlight) return this.handoverInFlight;
13813
+ this.handoverInFlight = this.releaseHandover(notice).finally(() => {
13814
+ this.handoverInFlight = null;
13815
+ });
13816
+ return this.handoverInFlight;
13817
+ }
13818
+ async releaseHandover(notice) {
13819
+ const startedAt = Date.now();
13820
+ const previous = this.socket;
13821
+ const candidate = this.createControlSocket(notice);
13822
+ let commandsTransferred = false;
13823
+ this.bindRpcRequestHandler(candidate, "control");
13824
+ const timeoutMs = Math.max(1, Math.min(1e4, notice.deadline - Date.now()));
13825
+ try {
13826
+ await new Promise((resolve, reject) => {
13827
+ const timer = setTimeout(() => reject(new Error("release handover timeout")), timeoutMs);
13828
+ candidate.once("connect", () => {
13829
+ clearTimeout(timer);
13830
+ resolve();
13831
+ });
13832
+ candidate.once("connect_error", (error) => {
13833
+ clearTimeout(timer);
13834
+ reject(error);
13835
+ });
13836
+ candidate.connect();
13837
+ });
13838
+ await this.rpcHandlerManager.onSocketConnectAndWait(candidate, timeoutMs);
13839
+ if (this.socket !== previous) {
13840
+ this.rpcHandlerManager.onSocketDisconnect(candidate);
13841
+ candidate.close();
13842
+ return;
13843
+ }
13844
+ this.unbindMachineCommandHandlers(previous);
13845
+ this.bindMachineCommandHandlers(candidate);
13846
+ commandsTransferred = true;
13847
+ this.bindControlDataHandlers(candidate);
13848
+ this.socket = candidate;
13849
+ candidate.on("connect", () => {
13850
+ if (this.socket === candidate) this.activateControlSocket(candidate);
13851
+ });
13852
+ candidate.on("disconnect", () => {
13853
+ this.rpcHandlerManager.onSocketDisconnect(candidate);
13854
+ if (!this.relaySocket?.connected) this.webTerminal.resetSubscribers();
13855
+ this.stopKeepAlive();
13856
+ if (this.socket === candidate) this.startSmartReconnect();
13857
+ });
13858
+ this.activateControlSocket(candidate, true);
13859
+ candidate.emit("release-handover-result", { result: "success", durationMs: Date.now() - startedAt });
13860
+ this.rpcHandlerManager.onSocketDisconnect(previous);
13861
+ previous.removeAllListeners("disconnect");
13862
+ previous.close();
13863
+ } catch (error) {
13864
+ if (this.socket === candidate) this.socket = previous;
13865
+ if (commandsTransferred) {
13866
+ this.unbindMachineCommandHandlers(candidate);
13867
+ this.bindMachineCommandHandlers(previous);
13868
+ }
13869
+ this.rpcHandlerManager.onSocketDisconnect(candidate);
13870
+ candidate.close();
13871
+ if (previous.connected) previous.emit("release-handover-result", { result: "failed", durationMs: Date.now() - startedAt });
13872
+ logger.debug(`[API MACHINE] Release handover failed: ${error instanceof Error ? error.message : error}`);
13873
+ }
13565
13874
  }
13566
13875
  startKeepAlive() {
13567
13876
  this.stopKeepAlive();
@@ -13595,11 +13904,13 @@ class ApiMachineClient {
13595
13904
  }, 2e4);
13596
13905
  logger.debug("[API MACHINE] Keep-alive started (20s interval)");
13597
13906
  }
13598
- bindRealtimeHandlers(socket, transport) {
13907
+ bindRpcRequestHandler(socket, transport) {
13599
13908
  socket.on("rpc-request", async (data, callback) => {
13600
13909
  logger.debugLargeJson(`[API MACHINE] Received RPC request via ${transport}:`, data);
13601
13910
  callback(await this.rpcHandlerManager.handleRequest(data));
13602
13911
  });
13912
+ }
13913
+ bindMachineCommandHandlers(socket) {
13603
13914
  socket.on("terminal-input", (data) => {
13604
13915
  let payload = data.data;
13605
13916
  if (data.enc) {
@@ -13617,6 +13928,16 @@ class ApiMachineClient {
13617
13928
  logger.debug(`[API MACHINE] Server archived session ${data.sessionId}; local process stopped=${stopped}`);
13618
13929
  });
13619
13930
  }
13931
+ unbindMachineCommandHandlers(socket) {
13932
+ socket.removeAllListeners("terminal-input");
13933
+ socket.removeAllListeners("terminal-resize");
13934
+ socket.removeAllListeners("terminal-close");
13935
+ socket.removeAllListeners("session-archive");
13936
+ }
13937
+ bindRealtimeHandlers(socket, transport) {
13938
+ this.bindRpcRequestHandler(socket, transport);
13939
+ this.bindMachineCommandHandlers(socket);
13940
+ }
13620
13941
  /** Reconcile commands missed while the daemon was offline. The server
13621
13942
  * returns only ids owned by this account and durably archived in the DB. */
13622
13943
  async reconcileArchivedSessions() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "very-happy-cli",
3
- "version": "0.2.75",
3
+ "version": "0.2.77",
4
4
  "description": "Remote multi-agent workspace CLI with deep Claude integration, native terminal streaming, and self-hosting.",
5
5
  "author": "Kirill Dubovitskiy",
6
6
  "contributors": [
@@ -113,12 +113,12 @@
113
113
  "zod": "^4.0.0"
114
114
  },
115
115
  "optionalDependencies": {
116
- "very-happy-tools-arm64-darwin": "0.2.75",
117
- "very-happy-tools-x64-darwin": "0.2.75",
118
- "very-happy-tools-arm64-linux": "0.2.75",
119
- "very-happy-tools-x64-linux": "0.2.75",
120
- "very-happy-tools-arm64-win32": "0.2.75",
121
- "very-happy-tools-x64-win32": "0.2.75"
116
+ "very-happy-tools-arm64-darwin": "0.2.77",
117
+ "very-happy-tools-x64-darwin": "0.2.77",
118
+ "very-happy-tools-arm64-linux": "0.2.77",
119
+ "very-happy-tools-x64-linux": "0.2.77",
120
+ "very-happy-tools-arm64-win32": "0.2.77",
121
+ "very-happy-tools-x64-win32": "0.2.77"
122
122
  },
123
123
  "devDependencies": {
124
124
  "@slopus/happy-wire": "workspace:*",