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
@@ -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.76";
54
+ var version = "0.2.78";
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.76",
168
- "very-happy-tools-x64-darwin": "0.2.76",
169
- "very-happy-tools-arm64-linux": "0.2.76",
170
- "very-happy-tools-x64-linux": "0.2.76",
171
- "very-happy-tools-arm64-win32": "0.2.76",
172
- "very-happy-tools-x64-win32": "0.2.76"
167
+ "very-happy-tools-arm64-darwin": "0.2.78",
168
+ "very-happy-tools-x64-darwin": "0.2.78",
169
+ "very-happy-tools-arm64-linux": "0.2.78",
170
+ "very-happy-tools-x64-linux": "0.2.78",
171
+ "very-happy-tools-arm64-win32": "0.2.78",
172
+ "very-happy-tools-x64-win32": "0.2.78"
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(),
@@ -951,6 +960,9 @@ const CliUpdateStateSchema = z.z.object({
951
960
  const WebTerminalListItemSchema = z.z.object({
952
961
  id: z.z.string(),
953
962
  title: z.z.string().optional(),
963
+ /** New daemons always send an array (including []); absence identifies an
964
+ * old daemon that cannot persist terminal tags. */
965
+ tags: z.z.array(z.z.string()).optional(),
954
966
  cwd: z.z.string().optional(),
955
967
  createdAt: z.z.number().optional(),
956
968
  activityAt: z.z.number().optional(),
@@ -1381,6 +1393,56 @@ class RpcHandlerManager {
1381
1393
  socket.emit("rpc-register", { method: prefixedMethod });
1382
1394
  }
1383
1395
  }
1396
+ /**
1397
+ * Attach a candidate transport and wait until the server confirms every
1398
+ * method before a release handover closes the old transport.
1399
+ */
1400
+ async onSocketConnectAndWait(socket, timeoutMs = 1e4) {
1401
+ this.sockets.add(socket);
1402
+ const pending = new Set(this.handlers.keys());
1403
+ const acknowledged = /* @__PURE__ */ new Set();
1404
+ await new Promise((resolve, reject) => {
1405
+ let settleTimer = null;
1406
+ const timer = setTimeout(() => {
1407
+ cleanup();
1408
+ reject(new Error(`RPC registration timed out (${pending.size} pending)`));
1409
+ }, timeoutMs);
1410
+ timer.unref?.();
1411
+ const maybeSettle = () => {
1412
+ if (pending.size > 0 || settleTimer) return;
1413
+ settleTimer = setTimeout(() => {
1414
+ settleTimer = null;
1415
+ for (const prefixedMethod of this.handlers.keys()) {
1416
+ if (acknowledged.has(prefixedMethod) || pending.has(prefixedMethod)) continue;
1417
+ pending.add(prefixedMethod);
1418
+ socket.emit("rpc-register", { method: prefixedMethod });
1419
+ }
1420
+ if (pending.size === 0) {
1421
+ cleanup();
1422
+ resolve();
1423
+ }
1424
+ }, 0);
1425
+ settleTimer.unref?.();
1426
+ };
1427
+ const onRegistered = (data) => {
1428
+ if (typeof data?.method !== "string") return;
1429
+ acknowledged.add(data.method);
1430
+ pending.delete(data.method);
1431
+ maybeSettle();
1432
+ };
1433
+ const cleanup = () => {
1434
+ clearTimeout(timer);
1435
+ if (settleTimer) clearTimeout(settleTimer);
1436
+ socket.off("rpc-registered", onRegistered);
1437
+ };
1438
+ socket.on("rpc-registered", onRegistered);
1439
+ for (const prefixedMethod of pending) socket.emit("rpc-register", { method: prefixedMethod });
1440
+ maybeSettle();
1441
+ }).catch((error) => {
1442
+ this.sockets.delete(socket);
1443
+ throw error;
1444
+ });
1445
+ }
1384
1446
  onSocketDisconnect(socket) {
1385
1447
  if (socket) this.sockets.delete(socket);
1386
1448
  else this.sockets.clear();
@@ -1415,7 +1477,7 @@ class RpcHandlerManager {
1415
1477
  }
1416
1478
  }
1417
1479
 
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-xqPsUU1d.cjs', document.baseURI).href))));
1480
+ 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-Dua2iF5S.cjs', document.baseURI).href))));
1419
1481
  function projectPath() {
1420
1482
  const path = path$1.resolve(__dirname$1, "..");
1421
1483
  return path;
@@ -2782,6 +2844,7 @@ class ApiSessionClient extends node_events.EventEmitter {
2782
2844
  receiveSync;
2783
2845
  directInboundLocalIds = /* @__PURE__ */ new Set();
2784
2846
  routedInboundLocalIds = /* @__PURE__ */ new Set();
2847
+ handoverInFlight = null;
2785
2848
  constructor(token, session) {
2786
2849
  super();
2787
2850
  this.token = token;
@@ -2801,26 +2864,15 @@ class ApiSessionClient extends node_events.EventEmitter {
2801
2864
  logger: (msg, data) => logger.debug(msg, data)
2802
2865
  });
2803
2866
  registerCommonHandlers(this.rpcHandlerManager, this.metadata.path);
2804
- this.socket = socket_ioClient.io(configuration.serverUrl, {
2805
- auth: {
2806
- token: this.token,
2807
- clientType: "session-scoped",
2808
- sessionId: this.sessionId,
2809
- happyClient: `cli-coding-session/${configuration.currentCliVersion}`
2810
- },
2811
- path: "/v1/updates",
2812
- reconnection: false,
2813
- transports: ["websocket"],
2814
- withCredentials: true,
2815
- autoConnect: false
2816
- });
2867
+ this.socket = this.createControlSocket();
2868
+ const controlSocket = this.socket;
2817
2869
  this.socket.on("connect", () => {
2818
2870
  logger.debug("Socket connected successfully");
2819
2871
  if (this.reconnectInterval) {
2820
2872
  clearInterval(this.reconnectInterval);
2821
2873
  this.reconnectInterval = null;
2822
2874
  }
2823
- this.rpcHandlerManager.onSocketConnect(this.socket);
2875
+ this.rpcHandlerManager.onSocketConnect(controlSocket);
2824
2876
  this.receiveSync.invalidate();
2825
2877
  void this.connectSessionRelay();
2826
2878
  });
@@ -2829,12 +2881,12 @@ class ApiSessionClient extends node_events.EventEmitter {
2829
2881
  });
2830
2882
  this.socket.on("disconnect", (reason) => {
2831
2883
  logger.debug(`[API] Socket disconnected: ${reason}`);
2832
- this.rpcHandlerManager.onSocketDisconnect();
2884
+ this.rpcHandlerManager.onSocketDisconnect(controlSocket);
2833
2885
  this.startSmartReconnect();
2834
2886
  });
2835
2887
  this.socket.on("connect_error", (error) => {
2836
2888
  logger.debug("[API] Socket connection error:", error);
2837
- this.rpcHandlerManager.onSocketDisconnect();
2889
+ this.rpcHandlerManager.onSocketDisconnect(controlSocket);
2838
2890
  if (error.message === "Session archived") {
2839
2891
  logger.debug("[API] Archived session rejected during reconnect; exiting");
2840
2892
  this.emit("archived");
@@ -2905,8 +2957,103 @@ class ApiSessionClient extends node_events.EventEmitter {
2905
2957
  this.socket.on("error", (error) => {
2906
2958
  logger.debug("[API] Socket error:", error);
2907
2959
  });
2960
+ this.socket.on("server-draining", (data) => {
2961
+ const parsed = ReleaseDrainNoticeSchema.safeParse(data);
2962
+ if (!parsed.success || parsed.data.deadline <= Date.now()) return;
2963
+ void this.startReleaseHandover(parsed.data);
2964
+ });
2908
2965
  this.socket.connect();
2909
2966
  }
2967
+ createControlSocket(handover) {
2968
+ return socket_ioClient.io(configuration.serverUrl, {
2969
+ auth: {
2970
+ token: this.token,
2971
+ clientType: "session-scoped",
2972
+ sessionId: this.sessionId,
2973
+ happyClient: `cli-coding-session/${configuration.currentCliVersion}`,
2974
+ ...handover ? { handoverEpoch: handover.epoch } : {}
2975
+ },
2976
+ ...handover ? { query: { vh_slot: handover.candidateSlot }, forceNew: true } : {},
2977
+ path: "/v1/updates",
2978
+ reconnection: false,
2979
+ transports: ["websocket"],
2980
+ withCredentials: true,
2981
+ autoConnect: false
2982
+ });
2983
+ }
2984
+ startReleaseHandover(notice) {
2985
+ if (this.handoverInFlight) return this.handoverInFlight;
2986
+ this.handoverInFlight = this.releaseHandover(notice).finally(() => {
2987
+ this.handoverInFlight = null;
2988
+ });
2989
+ return this.handoverInFlight;
2990
+ }
2991
+ async releaseHandover(notice) {
2992
+ const startedAt = Date.now();
2993
+ const previous = this.socket;
2994
+ const candidate = this.createControlSocket(notice);
2995
+ const transferredListeners = /* @__PURE__ */ new Map();
2996
+ for (const listener of previous.listeners("rpc-request")) candidate.on("rpc-request", listener);
2997
+ const timeoutMs = Math.max(1, Math.min(1e4, notice.deadline - Date.now()));
2998
+ try {
2999
+ await new Promise((resolve, reject) => {
3000
+ const timer = setTimeout(() => reject(new Error("release handover timeout")), timeoutMs);
3001
+ candidate.once("connect", () => {
3002
+ clearTimeout(timer);
3003
+ resolve();
3004
+ });
3005
+ candidate.once("connect_error", (error) => {
3006
+ clearTimeout(timer);
3007
+ reject(error);
3008
+ });
3009
+ candidate.connect();
3010
+ });
3011
+ await this.rpcHandlerManager.onSocketConnectAndWait(candidate, timeoutMs);
3012
+ if (this.socket !== previous) {
3013
+ this.rpcHandlerManager.onSocketDisconnect(candidate);
3014
+ candidate.close();
3015
+ return;
3016
+ }
3017
+ for (const event of ["session-archive", "update", "error", "server-draining"]) {
3018
+ const listeners = previous.listeners(event);
3019
+ previous.removeAllListeners(event);
3020
+ for (const listener of listeners) candidate.on(event, listener);
3021
+ transferredListeners.set(event, listeners);
3022
+ }
3023
+ this.socket = candidate;
3024
+ candidate.on("connect", () => {
3025
+ if (this.reconnectInterval) {
3026
+ clearInterval(this.reconnectInterval);
3027
+ this.reconnectInterval = null;
3028
+ }
3029
+ this.rpcHandlerManager.onSocketConnect(candidate);
3030
+ this.receiveSync.invalidate();
3031
+ });
3032
+ candidate.on("disconnect", () => {
3033
+ this.rpcHandlerManager.onSocketDisconnect(candidate);
3034
+ this.startSmartReconnect();
3035
+ });
3036
+ candidate.on("connect_error", () => {
3037
+ this.rpcHandlerManager.onSocketDisconnect(candidate);
3038
+ this.startSmartReconnect();
3039
+ });
3040
+ this.receiveSync.invalidate();
3041
+ candidate.emit("release-handover-result", { result: "success", durationMs: Date.now() - startedAt });
3042
+ this.rpcHandlerManager.onSocketDisconnect(previous);
3043
+ previous.removeAllListeners("disconnect");
3044
+ previous.close();
3045
+ } catch (error) {
3046
+ if (this.socket === candidate) this.socket = previous;
3047
+ for (const [event, listeners] of transferredListeners) {
3048
+ candidate.removeAllListeners(event);
3049
+ for (const listener of listeners) previous.on(event, listener);
3050
+ }
3051
+ this.rpcHandlerManager.onSocketDisconnect(candidate);
3052
+ candidate.close();
3053
+ if (previous.connected) previous.emit("release-handover-result", { result: "failed", durationMs: Date.now() - startedAt });
3054
+ logger.debug(`[API] Release handover failed: ${error instanceof Error ? error.message : error}`);
3055
+ }
3056
+ }
2910
3057
  onUserMessage(callback) {
2911
3058
  this.pendingMessageCallback = callback;
2912
3059
  while (this.pendingMessages.length > 0) {
@@ -9664,6 +9811,7 @@ function terminalListSignature(items) {
9664
9811
  const canon = [...items].sort((a, b) => a.id < b.id ? -1 : a.id > b.id ? 1 : 0).map((t) => [
9665
9812
  t.id,
9666
9813
  t.title ?? "",
9814
+ t.tags ?? [],
9667
9815
  t.cwd ?? "",
9668
9816
  t.createdAt ?? 0,
9669
9817
  Math.floor((t.activityAt ?? 0) / ACTIVITY_SIGNATURE_BUCKET_MS),
@@ -9697,6 +9845,7 @@ const LIST_SESSIONS_FORMAT = [
9697
9845
  "#{pane_current_path}",
9698
9846
  "#{@vh_title}",
9699
9847
  "#{@vh_title_manual}",
9848
+ "#{@vh_tags}",
9700
9849
  // B-121: the control-mode client has no `pty.process`, so the agent-state
9701
9850
  // fast path lost its live `#{pane_current_command}` equivalent. Carry it in
9702
9851
  // the ONE list-sessions call the tracker already makes — the value goes
@@ -9711,8 +9860,8 @@ const LIST_SESSIONS_FORMAT = [
9711
9860
  function parseSessionListLine(line) {
9712
9861
  if (!line) return void 0;
9713
9862
  const parts = line.split(LIST_FIELD_SEP);
9714
- if (parts.length < 8) return void 0;
9715
- const [name, created, activity, cwd, vhTitle, manual, paneCommand] = parts;
9863
+ if (parts.length < 9) return void 0;
9864
+ const [name, created, activity, cwd, vhTitle, manual, vhTags, paneCommand] = parts;
9716
9865
  if (!name) return void 0;
9717
9866
  return {
9718
9867
  name,
@@ -9721,12 +9870,40 @@ function parseSessionListLine(line) {
9721
9870
  cwd: cwd || void 0,
9722
9871
  vhTitle: vhTitle.trim() || void 0,
9723
9872
  manual: manual.trim().length > 0,
9873
+ tags: parseTerminalTags(vhTags),
9724
9874
  paneCurrentCommand: paneCommand.trim() || void 0,
9725
9875
  // pane_title is last, so anything after field 8 is title content that
9726
9876
  // contained the separator — rejoin it rather than dropping it.
9727
- paneTitle: parts.slice(7).join(LIST_FIELD_SEP) || void 0
9877
+ paneTitle: parts.slice(8).join(LIST_FIELD_SEP) || void 0
9728
9878
  };
9729
9879
  }
9880
+ const TERMINAL_TAG_MAX_COUNT = 64;
9881
+ const TERMINAL_TAG_MAX_LENGTH = 24;
9882
+ const TERMINAL_TAGS_MAX_BYTES = 4096;
9883
+ function validateTerminalTags(value) {
9884
+ if (!Array.isArray(value) || value.length > TERMINAL_TAG_MAX_COUNT) return void 0;
9885
+ const seen = /* @__PURE__ */ new Set();
9886
+ const out = [];
9887
+ for (const valueTag of value) {
9888
+ if (typeof valueTag !== "string") return void 0;
9889
+ const tag = valueTag.trim().replace(/^#+/, "").trim().replace(/\s+/g, "-").slice(0, TERMINAL_TAG_MAX_LENGTH);
9890
+ if (!tag || tag !== valueTag) return void 0;
9891
+ const key = tag.toLowerCase();
9892
+ if (seen.has(key)) return void 0;
9893
+ seen.add(key);
9894
+ out.push(tag);
9895
+ }
9896
+ if (Buffer.byteLength(JSON.stringify(out), "utf8") > TERMINAL_TAGS_MAX_BYTES) return void 0;
9897
+ return out;
9898
+ }
9899
+ function parseTerminalTags(raw) {
9900
+ if (typeof raw !== "string" || !raw.trim()) return [];
9901
+ try {
9902
+ return validateTerminalTags(JSON.parse(raw)) ?? [];
9903
+ } catch {
9904
+ return [];
9905
+ }
9906
+ }
9730
9907
  function classifyPane(currentCommand, tail) {
9731
9908
  const cmd = (currentCommand || "").trim().replace(/^-/, "").toLowerCase();
9732
9909
  const isShell = SHELL_COMMANDS.has(cmd);
@@ -11159,6 +11336,8 @@ class WebTerminalManager {
11159
11336
  out.push({
11160
11337
  id,
11161
11338
  title,
11339
+ // Always include [] so Web can feature-detect tag support.
11340
+ tags: s.tags,
11162
11341
  cwd: s.cwd,
11163
11342
  createdAt: s.created,
11164
11343
  // tmux last-activity (epoch s) → ms; optional so old daemons
@@ -11336,6 +11515,25 @@ class WebTerminalManager {
11336
11515
  return false;
11337
11516
  }
11338
11517
  }
11518
+ /** Persist terminal tags in tmux, parallel to @vh_title. */
11519
+ setTags(terminalId, tags) {
11520
+ if (!isTmuxAvailable()) return false;
11521
+ if (!/^[a-zA-Z0-9_-]{1,64}$/.test(terminalId)) return false;
11522
+ const valid = validateTerminalTags(tags);
11523
+ if (!valid) return false;
11524
+ const name = `vh-${terminalId}`;
11525
+ try {
11526
+ const r = node_child_process.spawnSync("tmux", ["set-option", "-t", name, "@vh_tags", JSON.stringify(valid)], {
11527
+ stdio: "ignore",
11528
+ env: ptyEnv()
11529
+ });
11530
+ if (r.status !== 0) return false;
11531
+ this.kickListRefresh();
11532
+ return true;
11533
+ } catch {
11534
+ return false;
11535
+ }
11536
+ }
11339
11537
  }
11340
11538
 
11341
11539
  const TMUX_TIMEOUT_MS = 3e3;
@@ -13107,6 +13305,7 @@ class ApiMachineClient {
13107
13305
  stopSessionHandler = null;
13108
13306
  listTrackedSessionIds = null;
13109
13307
  reconnectInterval = null;
13308
+ handoverInFlight = null;
13110
13309
  // Terminals negotiated with `encStream` protect the live byte payload with
13111
13310
  // the per-machine key. This keeps it opaque to passive storage/forwarding
13112
13311
  // paths, but is not an operator trust boundary: the trusted relay can
@@ -13283,6 +13482,16 @@ class ApiMachineClient {
13283
13482
  }
13284
13483
  return { type: "success" };
13285
13484
  });
13485
+ this.rpcHandlerManager.registerHandler("set-terminal-tags", async (params) => {
13486
+ const { terminalId, tags } = params || {};
13487
+ if (!terminalId || !Array.isArray(tags)) {
13488
+ throw new Error("terminalId and tags are required");
13489
+ }
13490
+ if (!this.webTerminal.setTags(terminalId, tags)) {
13491
+ throw new Error("Failed to set terminal tags (invalid tags, tmux unavailable, or session gone)");
13492
+ }
13493
+ return { type: "success" };
13494
+ });
13286
13495
  this.rpcHandlerManager.registerHandler("stop-session", (params) => {
13287
13496
  const { sessionId } = params || {};
13288
13497
  if (!sessionId) {
@@ -13567,57 +13776,72 @@ class ApiMachineClient {
13567
13776
  connect() {
13568
13777
  const serverUrl = configuration.serverUrl.replace(/^http/, "ws");
13569
13778
  logger.debug(`[API MACHINE] Connecting to ${serverUrl}`);
13570
- this.socket = socket_ioClient.io(serverUrl, {
13779
+ this.socket = this.createControlSocket();
13780
+ const controlSocket = this.socket;
13781
+ this.bindRealtimeHandlers(controlSocket, "control");
13782
+ controlSocket.on("connect", () => {
13783
+ if (this.socket !== controlSocket) return;
13784
+ this.activateControlSocket(controlSocket);
13785
+ });
13786
+ controlSocket.on("disconnect", (reason) => {
13787
+ logger.debug(`[API MACHINE] Disconnected from server \u2014 reason: ${reason}`);
13788
+ this.rpcHandlerManager.onSocketDisconnect(controlSocket);
13789
+ if (!this.relaySocket?.connected) this.webTerminal.resetSubscribers();
13790
+ this.stopKeepAlive();
13791
+ if (this.socket === controlSocket) this.startSmartReconnect();
13792
+ });
13793
+ this.bindControlDataHandlers(controlSocket);
13794
+ }
13795
+ createControlSocket(handover) {
13796
+ const serverUrl = configuration.serverUrl.replace(/^http/, "ws");
13797
+ return socket_ioClient.io(serverUrl, {
13571
13798
  transports: ["websocket"],
13572
13799
  auth: {
13573
13800
  token: this.token,
13574
13801
  clientType: "machine-scoped",
13575
13802
  machineId: this.machine.id,
13576
- happyClient: `cli-daemon/${configuration.currentCliVersion}`
13803
+ happyClient: `cli-daemon/${configuration.currentCliVersion}`,
13804
+ ...handover ? { handoverEpoch: handover.epoch } : {}
13577
13805
  },
13806
+ ...handover ? { query: { vh_slot: handover.candidateSlot }, forceNew: true } : {},
13578
13807
  path: "/v1/updates",
13579
- reconnection: false
13580
- });
13581
- this.bindRealtimeHandlers(this.socket, "control");
13582
- this.socket.on("connect", () => {
13583
- logger.debug("[API MACHINE] Connected to server");
13584
- if (this.reconnectInterval) {
13585
- clearInterval(this.reconnectInterval);
13586
- this.reconnectInterval = null;
13587
- }
13588
- const initialTerminals = this.webTerminal.buildTerminalList();
13589
- this.webTerminal.primeListSignature(initialTerminals);
13590
- this.updateDaemonState((state) => {
13591
- const now = Date.now();
13592
- const currentState = withCurrentCliUpdateState(state, this.cliUpdateState);
13593
- return {
13594
- ...currentState,
13595
- status: "running",
13596
- pid: process.pid,
13597
- httpPort: this.machine.daemonState?.httpPort,
13598
- startedAt: now,
13599
- webTerminals: { updatedAt: now, terminals: initialTerminals },
13600
- // B-084: closed records survive daemon restarts (persisted
13601
- // in closed-terminals.json), so the connect snapshot ships
13602
- // them too — not just the incremental pushes.
13603
- closedTerminals: this.webTerminal.getClosedTerminals()
13604
- };
13605
- });
13606
- this.webTerminal.startListTracking((terminals) => this.pushTerminalList(terminals));
13607
- this.rpcHandlerManager.onSocketConnect(this.socket);
13608
- this.syncResumeSessionRpcRegistration();
13609
- this.startKeepAlive();
13610
- void this.reconcileArchivedSessions();
13611
- void this.refreshRelayConnection();
13808
+ reconnection: false,
13809
+ ...handover ? { autoConnect: false } : {}
13612
13810
  });
13613
- this.socket.on("disconnect", (reason) => {
13614
- logger.debug(`[API MACHINE] Disconnected from server \u2014 reason: ${reason}`);
13615
- this.rpcHandlerManager.onSocketDisconnect(this.socket);
13616
- if (!this.relaySocket?.connected) this.webTerminal.resetSubscribers();
13617
- this.stopKeepAlive();
13618
- this.startSmartReconnect();
13811
+ }
13812
+ activateControlSocket(socket, rpcAlreadyRegistered = false) {
13813
+ logger.debug("[API MACHINE] Connected to server");
13814
+ if (this.reconnectInterval) {
13815
+ clearInterval(this.reconnectInterval);
13816
+ this.reconnectInterval = null;
13817
+ }
13818
+ const initialTerminals = this.webTerminal.buildTerminalList();
13819
+ this.webTerminal.primeListSignature(initialTerminals);
13820
+ this.updateDaemonState((state) => {
13821
+ const now = Date.now();
13822
+ const currentState = withCurrentCliUpdateState(state, this.cliUpdateState);
13823
+ return {
13824
+ ...currentState,
13825
+ status: "running",
13826
+ pid: process.pid,
13827
+ httpPort: this.machine.daemonState?.httpPort,
13828
+ startedAt: now,
13829
+ webTerminals: { updatedAt: now, terminals: initialTerminals },
13830
+ // B-084: closed records survive daemon restarts (persisted
13831
+ // in closed-terminals.json), so the connect snapshot ships
13832
+ // them too — not just the incremental pushes.
13833
+ closedTerminals: this.webTerminal.getClosedTerminals()
13834
+ };
13619
13835
  });
13620
- this.socket.on("update", (data) => {
13836
+ this.webTerminal.startListTracking((terminals) => this.pushTerminalList(terminals));
13837
+ if (!rpcAlreadyRegistered) this.rpcHandlerManager.onSocketConnect(socket);
13838
+ this.syncResumeSessionRpcRegistration();
13839
+ this.startKeepAlive();
13840
+ void this.reconcileArchivedSessions();
13841
+ void this.refreshRelayConnection();
13842
+ }
13843
+ bindControlDataHandlers(socket) {
13844
+ socket.on("update", (data) => {
13621
13845
  if (data.body.t === "update-machine" && data.body.machineId === this.machine.id) {
13622
13846
  const update = data.body;
13623
13847
  if (update.metadata) {
@@ -13634,13 +13858,83 @@ class ApiMachineClient {
13634
13858
  logger.debug(`[API MACHINE] Received unknown update type: ${data.body.t}`);
13635
13859
  }
13636
13860
  });
13637
- this.socket.on("connect_error", (error) => {
13861
+ socket.on("connect_error", (error) => {
13638
13862
  logger.debug(`[API MACHINE] Connection error: ${error.message}`);
13639
- this.startSmartReconnect();
13863
+ if (this.socket === socket) this.startSmartReconnect();
13640
13864
  });
13641
- this.socket.io.on("error", (error) => {
13865
+ socket.io.on("error", (error) => {
13642
13866
  logger.debug("[API MACHINE] Socket error:", error);
13643
13867
  });
13868
+ socket.on("server-draining", (data) => {
13869
+ if (this.socket !== socket) return;
13870
+ const parsed = ReleaseDrainNoticeSchema.safeParse(data);
13871
+ if (!parsed.success || parsed.data.deadline <= Date.now()) return;
13872
+ void this.startReleaseHandover(parsed.data);
13873
+ });
13874
+ }
13875
+ startReleaseHandover(notice) {
13876
+ if (this.handoverInFlight) return this.handoverInFlight;
13877
+ this.handoverInFlight = this.releaseHandover(notice).finally(() => {
13878
+ this.handoverInFlight = null;
13879
+ });
13880
+ return this.handoverInFlight;
13881
+ }
13882
+ async releaseHandover(notice) {
13883
+ const startedAt = Date.now();
13884
+ const previous = this.socket;
13885
+ const candidate = this.createControlSocket(notice);
13886
+ let commandsTransferred = false;
13887
+ this.bindRpcRequestHandler(candidate, "control");
13888
+ const timeoutMs = Math.max(1, Math.min(1e4, notice.deadline - Date.now()));
13889
+ try {
13890
+ await new Promise((resolve, reject) => {
13891
+ const timer = setTimeout(() => reject(new Error("release handover timeout")), timeoutMs);
13892
+ candidate.once("connect", () => {
13893
+ clearTimeout(timer);
13894
+ resolve();
13895
+ });
13896
+ candidate.once("connect_error", (error) => {
13897
+ clearTimeout(timer);
13898
+ reject(error);
13899
+ });
13900
+ candidate.connect();
13901
+ });
13902
+ await this.rpcHandlerManager.onSocketConnectAndWait(candidate, timeoutMs);
13903
+ if (this.socket !== previous) {
13904
+ this.rpcHandlerManager.onSocketDisconnect(candidate);
13905
+ candidate.close();
13906
+ return;
13907
+ }
13908
+ this.unbindMachineCommandHandlers(previous);
13909
+ this.bindMachineCommandHandlers(candidate);
13910
+ commandsTransferred = true;
13911
+ this.bindControlDataHandlers(candidate);
13912
+ this.socket = candidate;
13913
+ candidate.on("connect", () => {
13914
+ if (this.socket === candidate) this.activateControlSocket(candidate);
13915
+ });
13916
+ candidate.on("disconnect", () => {
13917
+ this.rpcHandlerManager.onSocketDisconnect(candidate);
13918
+ if (!this.relaySocket?.connected) this.webTerminal.resetSubscribers();
13919
+ this.stopKeepAlive();
13920
+ if (this.socket === candidate) this.startSmartReconnect();
13921
+ });
13922
+ this.activateControlSocket(candidate, true);
13923
+ candidate.emit("release-handover-result", { result: "success", durationMs: Date.now() - startedAt });
13924
+ this.rpcHandlerManager.onSocketDisconnect(previous);
13925
+ previous.removeAllListeners("disconnect");
13926
+ previous.close();
13927
+ } catch (error) {
13928
+ if (this.socket === candidate) this.socket = previous;
13929
+ if (commandsTransferred) {
13930
+ this.unbindMachineCommandHandlers(candidate);
13931
+ this.bindMachineCommandHandlers(previous);
13932
+ }
13933
+ this.rpcHandlerManager.onSocketDisconnect(candidate);
13934
+ candidate.close();
13935
+ if (previous.connected) previous.emit("release-handover-result", { result: "failed", durationMs: Date.now() - startedAt });
13936
+ logger.debug(`[API MACHINE] Release handover failed: ${error instanceof Error ? error.message : error}`);
13937
+ }
13644
13938
  }
13645
13939
  startKeepAlive() {
13646
13940
  this.stopKeepAlive();
@@ -13674,11 +13968,13 @@ class ApiMachineClient {
13674
13968
  }, 2e4);
13675
13969
  logger.debug("[API MACHINE] Keep-alive started (20s interval)");
13676
13970
  }
13677
- bindRealtimeHandlers(socket, transport) {
13971
+ bindRpcRequestHandler(socket, transport) {
13678
13972
  socket.on("rpc-request", async (data, callback) => {
13679
13973
  logger.debugLargeJson(`[API MACHINE] Received RPC request via ${transport}:`, data);
13680
13974
  callback(await this.rpcHandlerManager.handleRequest(data));
13681
13975
  });
13976
+ }
13977
+ bindMachineCommandHandlers(socket) {
13682
13978
  socket.on("terminal-input", (data) => {
13683
13979
  let payload = data.data;
13684
13980
  if (data.enc) {
@@ -13696,6 +13992,16 @@ class ApiMachineClient {
13696
13992
  logger.debug(`[API MACHINE] Server archived session ${data.sessionId}; local process stopped=${stopped}`);
13697
13993
  });
13698
13994
  }
13995
+ unbindMachineCommandHandlers(socket) {
13996
+ socket.removeAllListeners("terminal-input");
13997
+ socket.removeAllListeners("terminal-resize");
13998
+ socket.removeAllListeners("terminal-close");
13999
+ socket.removeAllListeners("session-archive");
14000
+ }
14001
+ bindRealtimeHandlers(socket, transport) {
14002
+ this.bindRpcRequestHandler(socket, transport);
14003
+ this.bindMachineCommandHandlers(socket);
14004
+ }
13699
14005
  /** Reconcile commands missed while the daemon was offline. The server
13700
14006
  * returns only ids owned by this account and durably archived in the DB. */
13701
14007
  async reconcileArchivedSessions() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "very-happy-cli",
3
- "version": "0.2.76",
3
+ "version": "0.2.78",
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.76",
117
- "very-happy-tools-x64-darwin": "0.2.76",
118
- "very-happy-tools-arm64-linux": "0.2.76",
119
- "very-happy-tools-x64-linux": "0.2.76",
120
- "very-happy-tools-arm64-win32": "0.2.76",
121
- "very-happy-tools-x64-win32": "0.2.76"
116
+ "very-happy-tools-arm64-darwin": "0.2.78",
117
+ "very-happy-tools-x64-darwin": "0.2.78",
118
+ "very-happy-tools-arm64-linux": "0.2.78",
119
+ "very-happy-tools-x64-linux": "0.2.78",
120
+ "very-happy-tools-arm64-win32": "0.2.78",
121
+ "very-happy-tools-x64-win32": "0.2.78"
122
122
  },
123
123
  "devDependencies": {
124
124
  "@slopus/happy-wire": "workspace:*",