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
|
@@ -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.
|
|
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.
|
|
168
|
-
"very-happy-tools-x64-darwin": "0.2.
|
|
169
|
-
"very-happy-tools-arm64-linux": "0.2.
|
|
170
|
-
"very-happy-tools-x64-linux": "0.2.
|
|
171
|
-
"very-happy-tools-arm64-win32": "0.2.
|
|
172
|
-
"very-happy-tools-x64-win32": "0.2.
|
|
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-
|
|
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 =
|
|
2805
|
-
|
|
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(
|
|
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 <
|
|
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(
|
|
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 =
|
|
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
|
-
|
|
13614
|
-
|
|
13615
|
-
|
|
13616
|
-
|
|
13617
|
-
this.
|
|
13618
|
-
this.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
117
|
-
"very-happy-tools-x64-darwin": "0.2.
|
|
118
|
-
"very-happy-tools-arm64-linux": "0.2.
|
|
119
|
-
"very-happy-tools-x64-linux": "0.2.
|
|
120
|
-
"very-happy-tools-arm64-win32": "0.2.
|
|
121
|
-
"very-happy-tools-x64-win32": "0.2.
|
|
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:*",
|