very-happy-cli 0.2.76 → 0.2.78

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-a8B0Y7EF.cjs → AcpBackend-BxZ-KvBy.cjs} +1 -1
  2. package/dist/{AcpBackend-KEFEf0fa.mjs → AcpBackend-CDngyx5G.mjs} +1 -1
  3. package/dist/{AcpSessionManager-rkoOuyxF.cjs → AcpSessionManager-BWFM_8f9.cjs} +1 -1
  4. package/dist/{AcpSessionManager-DpiEroK5.mjs → AcpSessionManager-Dcq8dl4l.mjs} +1 -1
  5. package/dist/{config-Bk2_O4iy.cjs → config-Byay91xM.cjs} +2 -2
  6. package/dist/{config-gCpPGROw.mjs → config-Co1Tpb5e.mjs} +2 -2
  7. package/dist/{index-KFtRhinT.cjs → index-CkjEzh-Y.cjs} +4 -4
  8. package/dist/{index-Baq7aDcp.cjs → index-CuvhWx_d.cjs} +15 -15
  9. package/dist/{index-BpR0Noyc.mjs → index-DdOYA0qe.mjs} +5 -5
  10. package/dist/{index-DBO5ydFO.mjs → index-w0zkr6EM.mjs} +12 -12
  11. package/dist/index.cjs +2 -2
  12. package/dist/index.mjs +2 -2
  13. package/dist/{installTerminalHooks-5MMeWip7.mjs → installTerminalHooks-DFmjn6EQ.mjs} +2 -2
  14. package/dist/{installTerminalHooks-CPbpttk4.cjs → installTerminalHooks-nXjKPP3D.cjs} +2 -2
  15. package/dist/lib.cjs +1 -1
  16. package/dist/lib.d.cts +21 -0
  17. package/dist/lib.d.mts +21 -0
  18. package/dist/lib.mjs +1 -1
  19. package/dist/{mcp-C0ai1fwz.cjs → mcp-BreMIH5r.cjs} +2 -2
  20. package/dist/{mcp-DYaTASOA.mjs → mcp-nlZLEkmO.mjs} +2 -2
  21. package/dist/{runGemini-DypVM7XW.mjs → runGemini-DEZ0neEl.mjs} +4 -4
  22. package/dist/{runGemini-BOhhFluL.cjs → runGemini-by29Tp8r.cjs} +4 -4
  23. package/dist/{runOpenClaw-CzYqRkGb.cjs → runOpenClaw-DENpTXhw.cjs} +3 -3
  24. package/dist/{runOpenClaw-BLJwhL9y.mjs → runOpenClaw-DyJQFzoX.mjs} +3 -3
  25. package/dist/{send-B4dnhYKC.cjs → send-e6BSMl0m.cjs} +2 -2
  26. package/dist/{send-BG-4q_Nb.mjs → send-pX36Sd4p.mjs} +2 -2
  27. package/dist/{spawn-BXj4rWNL.cjs → spawn-C87d_KDj.cjs} +2 -2
  28. package/dist/{spawn-BEwPBgbi.mjs → spawn-Da-94Ejo.mjs} +2 -2
  29. package/dist/{types-kLTNjWVl.mjs → types-Clmu74iY.mjs} +378 -72
  30. package/dist/{types-xqPsUU1d.cjs → types-Dua2iF5S.cjs} +379 -73
  31. package/package.json +7 -7
@@ -1,8 +1,8 @@
1
1
  import chalk from 'chalk';
2
2
  import { readFileSync, statSync } from 'node:fs';
3
3
  import { resolve } from 'node:path';
4
- import { c as checkIfDaemonRunningAndCleanupStaleState, s as spawnDaemonSession, a as sessionWebUrl, w as waitForSessionKey, b as sendUserMessage } from './index-DBO5ydFO.mjs';
5
- import { l as logger } from './types-kLTNjWVl.mjs';
4
+ import { c as checkIfDaemonRunningAndCleanupStaleState, s as spawnDaemonSession, a as sessionWebUrl, w as waitForSessionKey, b as sendUserMessage } from './index-w0zkr6EM.mjs';
5
+ import { l as logger } from './types-Clmu74iY.mjs';
6
6
  import 'node:os';
7
7
  import 'node:crypto';
8
8
  import 'cross-spawn';
@@ -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.76";
32
+ var version = "0.2.78";
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.76",
146
- "very-happy-tools-x64-darwin": "0.2.76",
147
- "very-happy-tools-arm64-linux": "0.2.76",
148
- "very-happy-tools-x64-linux": "0.2.76",
149
- "very-happy-tools-arm64-win32": "0.2.76",
150
- "very-happy-tools-x64-win32": "0.2.76"
145
+ "very-happy-tools-arm64-darwin": "0.2.78",
146
+ "very-happy-tools-x64-darwin": "0.2.78",
147
+ "very-happy-tools-arm64-linux": "0.2.78",
148
+ "very-happy-tools-x64-linux": "0.2.78",
149
+ "very-happy-tools-arm64-win32": "0.2.78",
150
+ "very-happy-tools-x64-win32": "0.2.78"
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(),
@@ -929,6 +938,9 @@ const CliUpdateStateSchema = z$1.object({
929
938
  const WebTerminalListItemSchema = z$1.object({
930
939
  id: z$1.string(),
931
940
  title: z$1.string().optional(),
941
+ /** New daemons always send an array (including []); absence identifies an
942
+ * old daemon that cannot persist terminal tags. */
943
+ tags: z$1.array(z$1.string()).optional(),
932
944
  cwd: z$1.string().optional(),
933
945
  createdAt: z$1.number().optional(),
934
946
  activityAt: z$1.number().optional(),
@@ -1359,6 +1371,56 @@ class RpcHandlerManager {
1359
1371
  socket.emit("rpc-register", { method: prefixedMethod });
1360
1372
  }
1361
1373
  }
1374
+ /**
1375
+ * Attach a candidate transport and wait until the server confirms every
1376
+ * method before a release handover closes the old transport.
1377
+ */
1378
+ async onSocketConnectAndWait(socket, timeoutMs = 1e4) {
1379
+ this.sockets.add(socket);
1380
+ const pending = new Set(this.handlers.keys());
1381
+ const acknowledged = /* @__PURE__ */ new Set();
1382
+ await new Promise((resolve, reject) => {
1383
+ let settleTimer = null;
1384
+ const timer = setTimeout(() => {
1385
+ cleanup();
1386
+ reject(new Error(`RPC registration timed out (${pending.size} pending)`));
1387
+ }, timeoutMs);
1388
+ timer.unref?.();
1389
+ const maybeSettle = () => {
1390
+ if (pending.size > 0 || settleTimer) return;
1391
+ settleTimer = setTimeout(() => {
1392
+ settleTimer = null;
1393
+ for (const prefixedMethod of this.handlers.keys()) {
1394
+ if (acknowledged.has(prefixedMethod) || pending.has(prefixedMethod)) continue;
1395
+ pending.add(prefixedMethod);
1396
+ socket.emit("rpc-register", { method: prefixedMethod });
1397
+ }
1398
+ if (pending.size === 0) {
1399
+ cleanup();
1400
+ resolve();
1401
+ }
1402
+ }, 0);
1403
+ settleTimer.unref?.();
1404
+ };
1405
+ const onRegistered = (data) => {
1406
+ if (typeof data?.method !== "string") return;
1407
+ acknowledged.add(data.method);
1408
+ pending.delete(data.method);
1409
+ maybeSettle();
1410
+ };
1411
+ const cleanup = () => {
1412
+ clearTimeout(timer);
1413
+ if (settleTimer) clearTimeout(settleTimer);
1414
+ socket.off("rpc-registered", onRegistered);
1415
+ };
1416
+ socket.on("rpc-registered", onRegistered);
1417
+ for (const prefixedMethod of pending) socket.emit("rpc-register", { method: prefixedMethod });
1418
+ maybeSettle();
1419
+ }).catch((error) => {
1420
+ this.sockets.delete(socket);
1421
+ throw error;
1422
+ });
1423
+ }
1362
1424
  onSocketDisconnect(socket) {
1363
1425
  if (socket) this.sockets.delete(socket);
1364
1426
  else this.sockets.clear();
@@ -2760,6 +2822,7 @@ class ApiSessionClient extends EventEmitter {
2760
2822
  receiveSync;
2761
2823
  directInboundLocalIds = /* @__PURE__ */ new Set();
2762
2824
  routedInboundLocalIds = /* @__PURE__ */ new Set();
2825
+ handoverInFlight = null;
2763
2826
  constructor(token, session) {
2764
2827
  super();
2765
2828
  this.token = token;
@@ -2779,26 +2842,15 @@ class ApiSessionClient extends EventEmitter {
2779
2842
  logger: (msg, data) => logger.debug(msg, data)
2780
2843
  });
2781
2844
  registerCommonHandlers(this.rpcHandlerManager, this.metadata.path);
2782
- this.socket = io(configuration.serverUrl, {
2783
- auth: {
2784
- token: this.token,
2785
- clientType: "session-scoped",
2786
- sessionId: this.sessionId,
2787
- happyClient: `cli-coding-session/${configuration.currentCliVersion}`
2788
- },
2789
- path: "/v1/updates",
2790
- reconnection: false,
2791
- transports: ["websocket"],
2792
- withCredentials: true,
2793
- autoConnect: false
2794
- });
2845
+ this.socket = this.createControlSocket();
2846
+ const controlSocket = this.socket;
2795
2847
  this.socket.on("connect", () => {
2796
2848
  logger.debug("Socket connected successfully");
2797
2849
  if (this.reconnectInterval) {
2798
2850
  clearInterval(this.reconnectInterval);
2799
2851
  this.reconnectInterval = null;
2800
2852
  }
2801
- this.rpcHandlerManager.onSocketConnect(this.socket);
2853
+ this.rpcHandlerManager.onSocketConnect(controlSocket);
2802
2854
  this.receiveSync.invalidate();
2803
2855
  void this.connectSessionRelay();
2804
2856
  });
@@ -2807,12 +2859,12 @@ class ApiSessionClient extends EventEmitter {
2807
2859
  });
2808
2860
  this.socket.on("disconnect", (reason) => {
2809
2861
  logger.debug(`[API] Socket disconnected: ${reason}`);
2810
- this.rpcHandlerManager.onSocketDisconnect();
2862
+ this.rpcHandlerManager.onSocketDisconnect(controlSocket);
2811
2863
  this.startSmartReconnect();
2812
2864
  });
2813
2865
  this.socket.on("connect_error", (error) => {
2814
2866
  logger.debug("[API] Socket connection error:", error);
2815
- this.rpcHandlerManager.onSocketDisconnect();
2867
+ this.rpcHandlerManager.onSocketDisconnect(controlSocket);
2816
2868
  if (error.message === "Session archived") {
2817
2869
  logger.debug("[API] Archived session rejected during reconnect; exiting");
2818
2870
  this.emit("archived");
@@ -2883,8 +2935,103 @@ class ApiSessionClient extends EventEmitter {
2883
2935
  this.socket.on("error", (error) => {
2884
2936
  logger.debug("[API] Socket error:", error);
2885
2937
  });
2938
+ this.socket.on("server-draining", (data) => {
2939
+ const parsed = ReleaseDrainNoticeSchema.safeParse(data);
2940
+ if (!parsed.success || parsed.data.deadline <= Date.now()) return;
2941
+ void this.startReleaseHandover(parsed.data);
2942
+ });
2886
2943
  this.socket.connect();
2887
2944
  }
2945
+ createControlSocket(handover) {
2946
+ return io(configuration.serverUrl, {
2947
+ auth: {
2948
+ token: this.token,
2949
+ clientType: "session-scoped",
2950
+ sessionId: this.sessionId,
2951
+ happyClient: `cli-coding-session/${configuration.currentCliVersion}`,
2952
+ ...handover ? { handoverEpoch: handover.epoch } : {}
2953
+ },
2954
+ ...handover ? { query: { vh_slot: handover.candidateSlot }, forceNew: true } : {},
2955
+ path: "/v1/updates",
2956
+ reconnection: false,
2957
+ transports: ["websocket"],
2958
+ withCredentials: true,
2959
+ autoConnect: false
2960
+ });
2961
+ }
2962
+ startReleaseHandover(notice) {
2963
+ if (this.handoverInFlight) return this.handoverInFlight;
2964
+ this.handoverInFlight = this.releaseHandover(notice).finally(() => {
2965
+ this.handoverInFlight = null;
2966
+ });
2967
+ return this.handoverInFlight;
2968
+ }
2969
+ async releaseHandover(notice) {
2970
+ const startedAt = Date.now();
2971
+ const previous = this.socket;
2972
+ const candidate = this.createControlSocket(notice);
2973
+ const transferredListeners = /* @__PURE__ */ new Map();
2974
+ for (const listener of previous.listeners("rpc-request")) candidate.on("rpc-request", listener);
2975
+ const timeoutMs = Math.max(1, Math.min(1e4, notice.deadline - Date.now()));
2976
+ try {
2977
+ await new Promise((resolve, reject) => {
2978
+ const timer = setTimeout(() => reject(new Error("release handover timeout")), timeoutMs);
2979
+ candidate.once("connect", () => {
2980
+ clearTimeout(timer);
2981
+ resolve();
2982
+ });
2983
+ candidate.once("connect_error", (error) => {
2984
+ clearTimeout(timer);
2985
+ reject(error);
2986
+ });
2987
+ candidate.connect();
2988
+ });
2989
+ await this.rpcHandlerManager.onSocketConnectAndWait(candidate, timeoutMs);
2990
+ if (this.socket !== previous) {
2991
+ this.rpcHandlerManager.onSocketDisconnect(candidate);
2992
+ candidate.close();
2993
+ return;
2994
+ }
2995
+ for (const event of ["session-archive", "update", "error", "server-draining"]) {
2996
+ const listeners = previous.listeners(event);
2997
+ previous.removeAllListeners(event);
2998
+ for (const listener of listeners) candidate.on(event, listener);
2999
+ transferredListeners.set(event, listeners);
3000
+ }
3001
+ this.socket = candidate;
3002
+ candidate.on("connect", () => {
3003
+ if (this.reconnectInterval) {
3004
+ clearInterval(this.reconnectInterval);
3005
+ this.reconnectInterval = null;
3006
+ }
3007
+ this.rpcHandlerManager.onSocketConnect(candidate);
3008
+ this.receiveSync.invalidate();
3009
+ });
3010
+ candidate.on("disconnect", () => {
3011
+ this.rpcHandlerManager.onSocketDisconnect(candidate);
3012
+ this.startSmartReconnect();
3013
+ });
3014
+ candidate.on("connect_error", () => {
3015
+ this.rpcHandlerManager.onSocketDisconnect(candidate);
3016
+ this.startSmartReconnect();
3017
+ });
3018
+ this.receiveSync.invalidate();
3019
+ candidate.emit("release-handover-result", { result: "success", durationMs: Date.now() - startedAt });
3020
+ this.rpcHandlerManager.onSocketDisconnect(previous);
3021
+ previous.removeAllListeners("disconnect");
3022
+ previous.close();
3023
+ } catch (error) {
3024
+ if (this.socket === candidate) this.socket = previous;
3025
+ for (const [event, listeners] of transferredListeners) {
3026
+ candidate.removeAllListeners(event);
3027
+ for (const listener of listeners) previous.on(event, listener);
3028
+ }
3029
+ this.rpcHandlerManager.onSocketDisconnect(candidate);
3030
+ candidate.close();
3031
+ if (previous.connected) previous.emit("release-handover-result", { result: "failed", durationMs: Date.now() - startedAt });
3032
+ logger.debug(`[API] Release handover failed: ${error instanceof Error ? error.message : error}`);
3033
+ }
3034
+ }
2888
3035
  onUserMessage(callback) {
2889
3036
  this.pendingMessageCallback = callback;
2890
3037
  while (this.pendingMessages.length > 0) {
@@ -9642,6 +9789,7 @@ function terminalListSignature(items) {
9642
9789
  const canon = [...items].sort((a, b) => a.id < b.id ? -1 : a.id > b.id ? 1 : 0).map((t) => [
9643
9790
  t.id,
9644
9791
  t.title ?? "",
9792
+ t.tags ?? [],
9645
9793
  t.cwd ?? "",
9646
9794
  t.createdAt ?? 0,
9647
9795
  Math.floor((t.activityAt ?? 0) / ACTIVITY_SIGNATURE_BUCKET_MS),
@@ -9675,6 +9823,7 @@ const LIST_SESSIONS_FORMAT = [
9675
9823
  "#{pane_current_path}",
9676
9824
  "#{@vh_title}",
9677
9825
  "#{@vh_title_manual}",
9826
+ "#{@vh_tags}",
9678
9827
  // B-121: the control-mode client has no `pty.process`, so the agent-state
9679
9828
  // fast path lost its live `#{pane_current_command}` equivalent. Carry it in
9680
9829
  // the ONE list-sessions call the tracker already makes — the value goes
@@ -9689,8 +9838,8 @@ const LIST_SESSIONS_FORMAT = [
9689
9838
  function parseSessionListLine(line) {
9690
9839
  if (!line) return void 0;
9691
9840
  const parts = line.split(LIST_FIELD_SEP);
9692
- if (parts.length < 8) return void 0;
9693
- const [name, created, activity, cwd, vhTitle, manual, paneCommand] = parts;
9841
+ if (parts.length < 9) return void 0;
9842
+ const [name, created, activity, cwd, vhTitle, manual, vhTags, paneCommand] = parts;
9694
9843
  if (!name) return void 0;
9695
9844
  return {
9696
9845
  name,
@@ -9699,12 +9848,40 @@ function parseSessionListLine(line) {
9699
9848
  cwd: cwd || void 0,
9700
9849
  vhTitle: vhTitle.trim() || void 0,
9701
9850
  manual: manual.trim().length > 0,
9851
+ tags: parseTerminalTags(vhTags),
9702
9852
  paneCurrentCommand: paneCommand.trim() || void 0,
9703
9853
  // pane_title is last, so anything after field 8 is title content that
9704
9854
  // contained the separator — rejoin it rather than dropping it.
9705
- paneTitle: parts.slice(7).join(LIST_FIELD_SEP) || void 0
9855
+ paneTitle: parts.slice(8).join(LIST_FIELD_SEP) || void 0
9706
9856
  };
9707
9857
  }
9858
+ const TERMINAL_TAG_MAX_COUNT = 64;
9859
+ const TERMINAL_TAG_MAX_LENGTH = 24;
9860
+ const TERMINAL_TAGS_MAX_BYTES = 4096;
9861
+ function validateTerminalTags(value) {
9862
+ if (!Array.isArray(value) || value.length > TERMINAL_TAG_MAX_COUNT) return void 0;
9863
+ const seen = /* @__PURE__ */ new Set();
9864
+ const out = [];
9865
+ for (const valueTag of value) {
9866
+ if (typeof valueTag !== "string") return void 0;
9867
+ const tag = valueTag.trim().replace(/^#+/, "").trim().replace(/\s+/g, "-").slice(0, TERMINAL_TAG_MAX_LENGTH);
9868
+ if (!tag || tag !== valueTag) return void 0;
9869
+ const key = tag.toLowerCase();
9870
+ if (seen.has(key)) return void 0;
9871
+ seen.add(key);
9872
+ out.push(tag);
9873
+ }
9874
+ if (Buffer.byteLength(JSON.stringify(out), "utf8") > TERMINAL_TAGS_MAX_BYTES) return void 0;
9875
+ return out;
9876
+ }
9877
+ function parseTerminalTags(raw) {
9878
+ if (typeof raw !== "string" || !raw.trim()) return [];
9879
+ try {
9880
+ return validateTerminalTags(JSON.parse(raw)) ?? [];
9881
+ } catch {
9882
+ return [];
9883
+ }
9884
+ }
9708
9885
  function classifyPane(currentCommand, tail) {
9709
9886
  const cmd = (currentCommand || "").trim().replace(/^-/, "").toLowerCase();
9710
9887
  const isShell = SHELL_COMMANDS.has(cmd);
@@ -11137,6 +11314,8 @@ class WebTerminalManager {
11137
11314
  out.push({
11138
11315
  id,
11139
11316
  title,
11317
+ // Always include [] so Web can feature-detect tag support.
11318
+ tags: s.tags,
11140
11319
  cwd: s.cwd,
11141
11320
  createdAt: s.created,
11142
11321
  // tmux last-activity (epoch s) → ms; optional so old daemons
@@ -11314,6 +11493,25 @@ class WebTerminalManager {
11314
11493
  return false;
11315
11494
  }
11316
11495
  }
11496
+ /** Persist terminal tags in tmux, parallel to @vh_title. */
11497
+ setTags(terminalId, tags) {
11498
+ if (!isTmuxAvailable()) return false;
11499
+ if (!/^[a-zA-Z0-9_-]{1,64}$/.test(terminalId)) return false;
11500
+ const valid = validateTerminalTags(tags);
11501
+ if (!valid) return false;
11502
+ const name = `vh-${terminalId}`;
11503
+ try {
11504
+ const r = spawnSync("tmux", ["set-option", "-t", name, "@vh_tags", JSON.stringify(valid)], {
11505
+ stdio: "ignore",
11506
+ env: ptyEnv()
11507
+ });
11508
+ if (r.status !== 0) return false;
11509
+ this.kickListRefresh();
11510
+ return true;
11511
+ } catch {
11512
+ return false;
11513
+ }
11514
+ }
11317
11515
  }
11318
11516
 
11319
11517
  const TMUX_TIMEOUT_MS = 3e3;
@@ -13085,6 +13283,7 @@ class ApiMachineClient {
13085
13283
  stopSessionHandler = null;
13086
13284
  listTrackedSessionIds = null;
13087
13285
  reconnectInterval = null;
13286
+ handoverInFlight = null;
13088
13287
  // Terminals negotiated with `encStream` protect the live byte payload with
13089
13288
  // the per-machine key. This keeps it opaque to passive storage/forwarding
13090
13289
  // paths, but is not an operator trust boundary: the trusted relay can
@@ -13261,6 +13460,16 @@ class ApiMachineClient {
13261
13460
  }
13262
13461
  return { type: "success" };
13263
13462
  });
13463
+ this.rpcHandlerManager.registerHandler("set-terminal-tags", async (params) => {
13464
+ const { terminalId, tags } = params || {};
13465
+ if (!terminalId || !Array.isArray(tags)) {
13466
+ throw new Error("terminalId and tags are required");
13467
+ }
13468
+ if (!this.webTerminal.setTags(terminalId, tags)) {
13469
+ throw new Error("Failed to set terminal tags (invalid tags, tmux unavailable, or session gone)");
13470
+ }
13471
+ return { type: "success" };
13472
+ });
13264
13473
  this.rpcHandlerManager.registerHandler("stop-session", (params) => {
13265
13474
  const { sessionId } = params || {};
13266
13475
  if (!sessionId) {
@@ -13545,57 +13754,72 @@ class ApiMachineClient {
13545
13754
  connect() {
13546
13755
  const serverUrl = configuration.serverUrl.replace(/^http/, "ws");
13547
13756
  logger.debug(`[API MACHINE] Connecting to ${serverUrl}`);
13548
- this.socket = io(serverUrl, {
13757
+ this.socket = this.createControlSocket();
13758
+ const controlSocket = this.socket;
13759
+ this.bindRealtimeHandlers(controlSocket, "control");
13760
+ controlSocket.on("connect", () => {
13761
+ if (this.socket !== controlSocket) return;
13762
+ this.activateControlSocket(controlSocket);
13763
+ });
13764
+ controlSocket.on("disconnect", (reason) => {
13765
+ logger.debug(`[API MACHINE] Disconnected from server \u2014 reason: ${reason}`);
13766
+ this.rpcHandlerManager.onSocketDisconnect(controlSocket);
13767
+ if (!this.relaySocket?.connected) this.webTerminal.resetSubscribers();
13768
+ this.stopKeepAlive();
13769
+ if (this.socket === controlSocket) this.startSmartReconnect();
13770
+ });
13771
+ this.bindControlDataHandlers(controlSocket);
13772
+ }
13773
+ createControlSocket(handover) {
13774
+ const serverUrl = configuration.serverUrl.replace(/^http/, "ws");
13775
+ return io(serverUrl, {
13549
13776
  transports: ["websocket"],
13550
13777
  auth: {
13551
13778
  token: this.token,
13552
13779
  clientType: "machine-scoped",
13553
13780
  machineId: this.machine.id,
13554
- happyClient: `cli-daemon/${configuration.currentCliVersion}`
13781
+ happyClient: `cli-daemon/${configuration.currentCliVersion}`,
13782
+ ...handover ? { handoverEpoch: handover.epoch } : {}
13555
13783
  },
13784
+ ...handover ? { query: { vh_slot: handover.candidateSlot }, forceNew: true } : {},
13556
13785
  path: "/v1/updates",
13557
- reconnection: false
13558
- });
13559
- this.bindRealtimeHandlers(this.socket, "control");
13560
- this.socket.on("connect", () => {
13561
- logger.debug("[API MACHINE] Connected to server");
13562
- if (this.reconnectInterval) {
13563
- clearInterval(this.reconnectInterval);
13564
- this.reconnectInterval = null;
13565
- }
13566
- const initialTerminals = this.webTerminal.buildTerminalList();
13567
- this.webTerminal.primeListSignature(initialTerminals);
13568
- this.updateDaemonState((state) => {
13569
- const now = Date.now();
13570
- const currentState = withCurrentCliUpdateState(state, this.cliUpdateState);
13571
- return {
13572
- ...currentState,
13573
- status: "running",
13574
- pid: process.pid,
13575
- httpPort: this.machine.daemonState?.httpPort,
13576
- startedAt: now,
13577
- webTerminals: { updatedAt: now, terminals: initialTerminals },
13578
- // B-084: closed records survive daemon restarts (persisted
13579
- // in closed-terminals.json), so the connect snapshot ships
13580
- // them too — not just the incremental pushes.
13581
- closedTerminals: this.webTerminal.getClosedTerminals()
13582
- };
13583
- });
13584
- this.webTerminal.startListTracking((terminals) => this.pushTerminalList(terminals));
13585
- this.rpcHandlerManager.onSocketConnect(this.socket);
13586
- this.syncResumeSessionRpcRegistration();
13587
- this.startKeepAlive();
13588
- void this.reconcileArchivedSessions();
13589
- void this.refreshRelayConnection();
13786
+ reconnection: false,
13787
+ ...handover ? { autoConnect: false } : {}
13590
13788
  });
13591
- this.socket.on("disconnect", (reason) => {
13592
- logger.debug(`[API MACHINE] Disconnected from server \u2014 reason: ${reason}`);
13593
- this.rpcHandlerManager.onSocketDisconnect(this.socket);
13594
- if (!this.relaySocket?.connected) this.webTerminal.resetSubscribers();
13595
- this.stopKeepAlive();
13596
- this.startSmartReconnect();
13789
+ }
13790
+ activateControlSocket(socket, rpcAlreadyRegistered = false) {
13791
+ logger.debug("[API MACHINE] Connected to server");
13792
+ if (this.reconnectInterval) {
13793
+ clearInterval(this.reconnectInterval);
13794
+ this.reconnectInterval = null;
13795
+ }
13796
+ const initialTerminals = this.webTerminal.buildTerminalList();
13797
+ this.webTerminal.primeListSignature(initialTerminals);
13798
+ this.updateDaemonState((state) => {
13799
+ const now = Date.now();
13800
+ const currentState = withCurrentCliUpdateState(state, this.cliUpdateState);
13801
+ return {
13802
+ ...currentState,
13803
+ status: "running",
13804
+ pid: process.pid,
13805
+ httpPort: this.machine.daemonState?.httpPort,
13806
+ startedAt: now,
13807
+ webTerminals: { updatedAt: now, terminals: initialTerminals },
13808
+ // B-084: closed records survive daemon restarts (persisted
13809
+ // in closed-terminals.json), so the connect snapshot ships
13810
+ // them too — not just the incremental pushes.
13811
+ closedTerminals: this.webTerminal.getClosedTerminals()
13812
+ };
13597
13813
  });
13598
- this.socket.on("update", (data) => {
13814
+ this.webTerminal.startListTracking((terminals) => this.pushTerminalList(terminals));
13815
+ if (!rpcAlreadyRegistered) this.rpcHandlerManager.onSocketConnect(socket);
13816
+ this.syncResumeSessionRpcRegistration();
13817
+ this.startKeepAlive();
13818
+ void this.reconcileArchivedSessions();
13819
+ void this.refreshRelayConnection();
13820
+ }
13821
+ bindControlDataHandlers(socket) {
13822
+ socket.on("update", (data) => {
13599
13823
  if (data.body.t === "update-machine" && data.body.machineId === this.machine.id) {
13600
13824
  const update = data.body;
13601
13825
  if (update.metadata) {
@@ -13612,13 +13836,83 @@ class ApiMachineClient {
13612
13836
  logger.debug(`[API MACHINE] Received unknown update type: ${data.body.t}`);
13613
13837
  }
13614
13838
  });
13615
- this.socket.on("connect_error", (error) => {
13839
+ socket.on("connect_error", (error) => {
13616
13840
  logger.debug(`[API MACHINE] Connection error: ${error.message}`);
13617
- this.startSmartReconnect();
13841
+ if (this.socket === socket) this.startSmartReconnect();
13618
13842
  });
13619
- this.socket.io.on("error", (error) => {
13843
+ socket.io.on("error", (error) => {
13620
13844
  logger.debug("[API MACHINE] Socket error:", error);
13621
13845
  });
13846
+ socket.on("server-draining", (data) => {
13847
+ if (this.socket !== socket) return;
13848
+ const parsed = ReleaseDrainNoticeSchema.safeParse(data);
13849
+ if (!parsed.success || parsed.data.deadline <= Date.now()) return;
13850
+ void this.startReleaseHandover(parsed.data);
13851
+ });
13852
+ }
13853
+ startReleaseHandover(notice) {
13854
+ if (this.handoverInFlight) return this.handoverInFlight;
13855
+ this.handoverInFlight = this.releaseHandover(notice).finally(() => {
13856
+ this.handoverInFlight = null;
13857
+ });
13858
+ return this.handoverInFlight;
13859
+ }
13860
+ async releaseHandover(notice) {
13861
+ const startedAt = Date.now();
13862
+ const previous = this.socket;
13863
+ const candidate = this.createControlSocket(notice);
13864
+ let commandsTransferred = false;
13865
+ this.bindRpcRequestHandler(candidate, "control");
13866
+ const timeoutMs = Math.max(1, Math.min(1e4, notice.deadline - Date.now()));
13867
+ try {
13868
+ await new Promise((resolve, reject) => {
13869
+ const timer = setTimeout(() => reject(new Error("release handover timeout")), timeoutMs);
13870
+ candidate.once("connect", () => {
13871
+ clearTimeout(timer);
13872
+ resolve();
13873
+ });
13874
+ candidate.once("connect_error", (error) => {
13875
+ clearTimeout(timer);
13876
+ reject(error);
13877
+ });
13878
+ candidate.connect();
13879
+ });
13880
+ await this.rpcHandlerManager.onSocketConnectAndWait(candidate, timeoutMs);
13881
+ if (this.socket !== previous) {
13882
+ this.rpcHandlerManager.onSocketDisconnect(candidate);
13883
+ candidate.close();
13884
+ return;
13885
+ }
13886
+ this.unbindMachineCommandHandlers(previous);
13887
+ this.bindMachineCommandHandlers(candidate);
13888
+ commandsTransferred = true;
13889
+ this.bindControlDataHandlers(candidate);
13890
+ this.socket = candidate;
13891
+ candidate.on("connect", () => {
13892
+ if (this.socket === candidate) this.activateControlSocket(candidate);
13893
+ });
13894
+ candidate.on("disconnect", () => {
13895
+ this.rpcHandlerManager.onSocketDisconnect(candidate);
13896
+ if (!this.relaySocket?.connected) this.webTerminal.resetSubscribers();
13897
+ this.stopKeepAlive();
13898
+ if (this.socket === candidate) this.startSmartReconnect();
13899
+ });
13900
+ this.activateControlSocket(candidate, true);
13901
+ candidate.emit("release-handover-result", { result: "success", durationMs: Date.now() - startedAt });
13902
+ this.rpcHandlerManager.onSocketDisconnect(previous);
13903
+ previous.removeAllListeners("disconnect");
13904
+ previous.close();
13905
+ } catch (error) {
13906
+ if (this.socket === candidate) this.socket = previous;
13907
+ if (commandsTransferred) {
13908
+ this.unbindMachineCommandHandlers(candidate);
13909
+ this.bindMachineCommandHandlers(previous);
13910
+ }
13911
+ this.rpcHandlerManager.onSocketDisconnect(candidate);
13912
+ candidate.close();
13913
+ if (previous.connected) previous.emit("release-handover-result", { result: "failed", durationMs: Date.now() - startedAt });
13914
+ logger.debug(`[API MACHINE] Release handover failed: ${error instanceof Error ? error.message : error}`);
13915
+ }
13622
13916
  }
13623
13917
  startKeepAlive() {
13624
13918
  this.stopKeepAlive();
@@ -13652,11 +13946,13 @@ class ApiMachineClient {
13652
13946
  }, 2e4);
13653
13947
  logger.debug("[API MACHINE] Keep-alive started (20s interval)");
13654
13948
  }
13655
- bindRealtimeHandlers(socket, transport) {
13949
+ bindRpcRequestHandler(socket, transport) {
13656
13950
  socket.on("rpc-request", async (data, callback) => {
13657
13951
  logger.debugLargeJson(`[API MACHINE] Received RPC request via ${transport}:`, data);
13658
13952
  callback(await this.rpcHandlerManager.handleRequest(data));
13659
13953
  });
13954
+ }
13955
+ bindMachineCommandHandlers(socket) {
13660
13956
  socket.on("terminal-input", (data) => {
13661
13957
  let payload = data.data;
13662
13958
  if (data.enc) {
@@ -13674,6 +13970,16 @@ class ApiMachineClient {
13674
13970
  logger.debug(`[API MACHINE] Server archived session ${data.sessionId}; local process stopped=${stopped}`);
13675
13971
  });
13676
13972
  }
13973
+ unbindMachineCommandHandlers(socket) {
13974
+ socket.removeAllListeners("terminal-input");
13975
+ socket.removeAllListeners("terminal-resize");
13976
+ socket.removeAllListeners("terminal-close");
13977
+ socket.removeAllListeners("session-archive");
13978
+ }
13979
+ bindRealtimeHandlers(socket, transport) {
13980
+ this.bindRpcRequestHandler(socket, transport);
13981
+ this.bindMachineCommandHandlers(socket);
13982
+ }
13677
13983
  /** Reconcile commands missed while the daemon was offline. The server
13678
13984
  * returns only ids owned by this account and durably archived in the DB. */
13679
13985
  async reconcileArchivedSessions() {