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.
- package/dist/{AcpBackend-a8B0Y7EF.cjs → AcpBackend-BxZ-KvBy.cjs} +1 -1
- package/dist/{AcpBackend-KEFEf0fa.mjs → AcpBackend-CDngyx5G.mjs} +1 -1
- package/dist/{AcpSessionManager-rkoOuyxF.cjs → AcpSessionManager-BWFM_8f9.cjs} +1 -1
- package/dist/{AcpSessionManager-DpiEroK5.mjs → AcpSessionManager-Dcq8dl4l.mjs} +1 -1
- package/dist/{config-Bk2_O4iy.cjs → config-Byay91xM.cjs} +2 -2
- package/dist/{config-gCpPGROw.mjs → config-Co1Tpb5e.mjs} +2 -2
- package/dist/{index-KFtRhinT.cjs → index-CkjEzh-Y.cjs} +4 -4
- package/dist/{index-Baq7aDcp.cjs → index-CuvhWx_d.cjs} +15 -15
- package/dist/{index-BpR0Noyc.mjs → index-DdOYA0qe.mjs} +5 -5
- package/dist/{index-DBO5ydFO.mjs → index-w0zkr6EM.mjs} +12 -12
- package/dist/index.cjs +2 -2
- package/dist/index.mjs +2 -2
- package/dist/{installTerminalHooks-5MMeWip7.mjs → installTerminalHooks-DFmjn6EQ.mjs} +2 -2
- package/dist/{installTerminalHooks-CPbpttk4.cjs → installTerminalHooks-nXjKPP3D.cjs} +2 -2
- package/dist/lib.cjs +1 -1
- package/dist/lib.d.cts +21 -0
- package/dist/lib.d.mts +21 -0
- package/dist/lib.mjs +1 -1
- package/dist/{mcp-C0ai1fwz.cjs → mcp-BreMIH5r.cjs} +2 -2
- package/dist/{mcp-DYaTASOA.mjs → mcp-nlZLEkmO.mjs} +2 -2
- package/dist/{runGemini-DypVM7XW.mjs → runGemini-DEZ0neEl.mjs} +4 -4
- package/dist/{runGemini-BOhhFluL.cjs → runGemini-by29Tp8r.cjs} +4 -4
- package/dist/{runOpenClaw-CzYqRkGb.cjs → runOpenClaw-DENpTXhw.cjs} +3 -3
- package/dist/{runOpenClaw-BLJwhL9y.mjs → runOpenClaw-DyJQFzoX.mjs} +3 -3
- package/dist/{send-B4dnhYKC.cjs → send-e6BSMl0m.cjs} +2 -2
- package/dist/{send-BG-4q_Nb.mjs → send-pX36Sd4p.mjs} +2 -2
- package/dist/{spawn-BXj4rWNL.cjs → spawn-C87d_KDj.cjs} +2 -2
- package/dist/{spawn-BEwPBgbi.mjs → spawn-Da-94Ejo.mjs} +2 -2
- package/dist/{types-kLTNjWVl.mjs → types-Clmu74iY.mjs} +378 -72
- package/dist/{types-xqPsUU1d.cjs → types-Dua2iF5S.cjs} +379 -73
- 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-
|
|
5
|
-
import { l as logger } from './types-
|
|
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.
|
|
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.
|
|
146
|
-
"very-happy-tools-x64-darwin": "0.2.
|
|
147
|
-
"very-happy-tools-arm64-linux": "0.2.
|
|
148
|
-
"very-happy-tools-x64-linux": "0.2.
|
|
149
|
-
"very-happy-tools-arm64-win32": "0.2.
|
|
150
|
-
"very-happy-tools-x64-win32": "0.2.
|
|
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 =
|
|
2783
|
-
|
|
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(
|
|
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 <
|
|
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(
|
|
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 =
|
|
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
|
-
|
|
13592
|
-
|
|
13593
|
-
|
|
13594
|
-
|
|
13595
|
-
this.
|
|
13596
|
-
this.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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() {
|